From 40bf3fd1f881cd25b259a826ea6ab7cb951c9db4 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Tue, 24 Feb 2026 15:30:45 +0900 Subject: [PATCH 1/9] =?UTF-8?q?[NDGL-118]=20feature:=20=EC=83=88=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EC=B6=94=EA=B0=80=20=EC=8B=9C=20Home,=20M?= =?UTF-8?q?yTravel=20Screen=EC=97=90=20=EB=B0=98=EC=98=81(Event-Bus=20?= =?UTF-8?q?=ED=8C=A8=ED=84=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/travel/model/TravelCreatedEvent.kt | 6 ++ .../travel/repository/UserTravelRepository.kt | 70 ++++++++++++++++--- .../ndgl/feature/home/main/HomeViewModel.kt | 8 +++ .../travel/datepicker/DatePickerViewModel.kt | 13 +++- .../travel/mytravel/MyTravelViewModel.kt | 25 +++++++ 5 files changed, 113 insertions(+), 9 deletions(-) create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TravelCreatedEvent.kt diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TravelCreatedEvent.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TravelCreatedEvent.kt new file mode 100644 index 00000000..4327b951 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TravelCreatedEvent.kt @@ -0,0 +1,6 @@ +package com.yapp.ndgl.data.travel.model + +data class TravelCreatedEvent( + val userTravelId: Long, + val templateId: Long, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt index c4197831..d0a7c149 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt @@ -3,13 +3,16 @@ package com.yapp.ndgl.data.travel.repository import com.yapp.ndgl.data.core.model.error.HttpResponseException import com.yapp.ndgl.data.core.model.getData import com.yapp.ndgl.data.travel.api.UserTravelApi +import com.yapp.ndgl.data.travel.model.AddItineraryRequest import com.yapp.ndgl.data.travel.model.AddPlaceEvent import com.yapp.ndgl.data.travel.model.BulkUpdateStartTimeRequest import com.yapp.ndgl.data.travel.model.ItineraryUpdateItem import com.yapp.ndgl.data.travel.model.StartTimeUpdateItem +import com.yapp.ndgl.data.travel.model.TravelCreatedEvent import com.yapp.ndgl.data.travel.model.UpcomingTravelList import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse import com.yapp.ndgl.data.travel.model.UpdateItineraryRequest +import com.yapp.ndgl.data.travel.model.UpdateTravelPlaceRequest import com.yapp.ndgl.data.travel.model.UserTravelTemplateContentInfo import com.yapp.ndgl.data.travel.model.UserTravelTemplateItinerary import kotlinx.coroutines.channels.BufferOverflow @@ -31,10 +34,21 @@ class UserTravelRepository @Inject constructor( ) val addPlaceEvent: SharedFlow = _addPlaceEvent.asSharedFlow() + private val _travelCreatedEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val travelCreatedEvent: SharedFlow = _travelCreatedEvent.asSharedFlow() + suspend fun emitAddPlaceEvent(event: AddPlaceEvent) { _addPlaceEvent.emit(event) } + suspend fun emitTravelCreatedEvent(event: TravelCreatedEvent) { + _travelCreatedEvent.emit(event) + } + suspend fun getUpcomingTravel(): UpcomingTravelResponse? { return try { userTravelApi.getUpcomingTravel().getData() @@ -51,6 +65,24 @@ class UserTravelRepository @Inject constructor( return userTravelApi.getUpcomingTravelList().getData() } + suspend fun getUserTravelTemplateItinerary( + travelId: Long, + day: Int, + ): UserTravelTemplateItinerary { + return userTravelApi.getUserTravelTemplateItinerary( + id = travelId, + day = day, + ).getData() + } + + suspend fun getUserTravelTemplateContentInfo( + travelId: Long, + ): UserTravelTemplateContentInfo { + return userTravelApi.getUserTravelTemplateContentInfo( + id = travelId, + ).getData() + } + suspend fun bulkUpdateStartTime( travelId: Long, updates: List, @@ -71,21 +103,43 @@ class UserTravelRepository @Inject constructor( ).getData() } - suspend fun getUserTravelTemplateItinerary( + suspend fun updateTravelPlace( travelId: Long, - day: Int, - ): UserTravelTemplateItinerary { - return userTravelApi.getUserTravelTemplateItinerary( + userTravelPlaceId: Long, + memo: String? = null, + cost: Int? = null, + ) { + userTravelApi.updateTravelPlace( id = travelId, - day = day, + userTravelPlaceId = userTravelPlaceId, + request = UpdateTravelPlaceRequest( + memo = memo, + cost = cost, + ), ).getData() } - suspend fun getUserTravelTemplateContentInfo( + suspend fun addItinerary( travelId: Long, - ): UserTravelTemplateContentInfo { - return userTravelApi.getUserTravelTemplateContentInfo( + googlePlaceId: String, + day: Int, + sequence: Int, + startTime: String? = null, + estimatedDuration: String? = null, + cost: Int? = null, + memo: String? = null, + ) { + userTravelApi.addItinerary( id = travelId, + request = AddItineraryRequest( + googlePlaceId = googlePlaceId, + day = day, + sequence = sequence, + startTime = startTime, + estimatedDuration = estimatedDuration, + cost = cost, + memo = memo, + ), ).getData() } } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt index c9f30652..72dd154a 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/HomeViewModel.kt @@ -29,6 +29,7 @@ class HomeViewModel @Inject constructor( ) { init { loadHomeContents() + subscribeToTravelCreatedEvent() } private fun loadHomeContents() { @@ -37,6 +38,13 @@ class HomeViewModel @Inject constructor( loadRecommendedTravel() } + private fun subscribeToTravelCreatedEvent() = viewModelScope.launch { + userTravelRepository.travelCreatedEvent.collect { event -> + // 새 여행 생성 시 내 여행 섹션만 새로고침 + loadMyTravel() + } + } + private fun loadMyTravel() { viewModelScope.launch { suspendRunCatching { userTravelRepository.getUpcomingTravel() } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/datepicker/DatePickerViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/datepicker/DatePickerViewModel.kt index 1174c6a1..e0bf7ee4 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/datepicker/DatePickerViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/datepicker/DatePickerViewModel.kt @@ -4,7 +4,9 @@ import androidx.lifecycle.viewModelScope import com.yapp.ndgl.core.base.BaseViewModel import com.yapp.ndgl.core.util.suspendRunCatching import com.yapp.ndgl.data.travel.exception.DuplicateTravelPeriodException +import com.yapp.ndgl.data.travel.model.TravelCreatedEvent import com.yapp.ndgl.data.travel.repository.TravelTemplateRepository +import com.yapp.ndgl.data.travel.repository.UserTravelRepository import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject @@ -18,6 +20,7 @@ class DatePickerViewModel @AssistedInject constructor( @Assisted private val templateId: Long, @Assisted private val tripDays: Int, private val travelTemplateRepository: TravelTemplateRepository, + private val userTravelRepository: UserTravelRepository, ) : BaseViewModel( initialState = DatePickerState(templateId = templateId, tripDays = tripDays), ) { @@ -126,6 +129,14 @@ class DatePickerViewModel @AssistedInject constructor( endDate = endDate.toString(), ) }.onSuccess { response -> + // 이벤트 발행 - 홈/내 여행 탭 자동 새로고침 + userTravelRepository.emitTravelCreatedEvent( + TravelCreatedEvent( + userTravelId = response.userTravelId, + templateId = templateId, + ), + ) + reduce { copy( isLoading = false, @@ -143,7 +154,7 @@ class DatePickerViewModel @AssistedInject constructor( } else -> { - // FIXME: UI 구현 필요 - 일반 에러 토스트 표시 + // FIXME: UI 구현 필요 Timber.e(exception, "Failed to create travel from template") } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelViewModel.kt index 704718b9..29a9c7fc 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelViewModel.kt @@ -37,6 +37,9 @@ class MyTravelViewModel @Inject constructor( loadRecommendedTravels() } } + + // 초기 로드 후 이벤트 구독 + subscribeToTravelCreatedEvent() } private suspend fun loadUpcomingTravel(): MyTravelState.UpcomingTravel? { @@ -128,6 +131,28 @@ class MyTravelViewModel @Inject constructor( } } + private fun subscribeToTravelCreatedEvent() = viewModelScope.launch { + userTravelRepository.travelCreatedEvent.collect { event -> + refreshTravelData() + } + } + + private fun refreshTravelData() = viewModelScope.launch { + // 병렬로 두 API 호출 + val upcomingDeferred = async { loadUpcomingTravel() } + val listDeferred = async { loadUpcomingTravelList() } + + val upcomingTravel = upcomingDeferred.await() + val upcomingTravels = listDeferred.await() + + reduce { copy(upcomingTravel = upcomingTravel, upcomingTravels = upcomingTravels) } + + // 여행 목록이 비어있으면 추천 여행 로드 + if (upcomingTravel == null && upcomingTravels.isEmpty()) { + loadRecommendedTravels() + } + } + override suspend fun handleIntent(intent: MyTravelIntent) { when (intent) { MyTravelIntent.ClickSearchTravelTemplate -> postNavigateToSearchTravelTemplate() From aa9db63b71bfff0b0d191d55a5656ad44972a52a Mon Sep 17 00:00:00 2001 From: mj010504 Date: Tue, 24 Feb 2026 15:31:46 +0900 Subject: [PATCH 2/9] =?UTF-8?q?[NDGL-118]=20fix:=20InputModal=EC=97=90=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=9E=85=EB=A0=A5=20=EC=8B=9C=20?= =?UTF-8?q?=EC=9E=90=EC=9D=8C/=EB=AA=A8=EC=9D=8C=20=EA=B2=B0=ED=95=A9?= =?UTF-8?q?=EC=9D=B4=20=EC=95=88=EB=90=98=EB=8D=98=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/ndgl/core/ui/designsystem/NDGLInputModal.kt | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLInputModal.kt b/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLInputModal.kt index 7f2cd805..01b01944 100644 --- a/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLInputModal.kt +++ b/core/ui/src/main/java/com/yapp/ndgl/core/ui/designsystem/NDGLInputModal.kt @@ -57,7 +57,7 @@ fun NDGLInputModal( textAlign: TextAlign = TextAlign.Start, ) { val focusRequester = remember { FocusRequester() } - var textFieldValue by remember(value) { + var textFieldValue by remember { mutableStateOf(TextFieldValue(value, selection = TextRange(value.length))) } @@ -65,16 +65,6 @@ fun NDGLInputModal( focusRequester.requestFocus() } - // value가 변경되면 textFieldValue 업데이트 - LaunchedEffect(value) { - if (textFieldValue.text != value) { - textFieldValue = TextFieldValue( - text = value, - selection = TextRange(value.length), - ) - } - } - Dialog(onDismissRequest = onDismissRequest) { Surface( modifier = modifier.wrapContentHeight(), From d01c4de91772c7e6e51384737f8ee217674535bd Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 17:37:44 +0900 Subject: [PATCH 3/9] =?UTF-8?q?[NDGL-118]=20feature:=20=EC=9D=B8=EA=B8=B0?= =?UTF-8?q?=20=EC=97=AC=ED=96=89=20=EB=94=B0=EB=9D=BC=EA=B0=80=EA=B8=B0=20?= =?UTF-8?q?=EB=8D=94=20=EB=B3=B4=EA=B8=B0,=20=EC=97=AC=ED=96=89=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=EC=97=90=EC=84=9C=20=EC=B9=B4=EB=93=9C=20?= =?UTF-8?q?=ED=81=B4=EB=A6=AD=20=EC=8B=9C=20FollowTravel=20=EC=97=B0?= =?UTF-8?q?=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/ndgl/feature/home/component/TravelTemplate.kt | 4 ++-- .../yapp/ndgl/feature/home/main/PopularTravelSection.kt | 2 +- .../feature/home/popular/PopularTravelListContract.kt | 2 +- .../ndgl/feature/home/popular/PopularTravelListScreen.kt | 6 +++--- .../feature/home/popular/PopularTravelListViewModel.kt | 6 +++--- .../ndgl/feature/home/search/TemplateSearchContract.kt | 2 +- .../yapp/ndgl/feature/home/search/TemplateSearchScreen.kt | 8 ++++---- .../ndgl/feature/home/search/TemplateSearchViewModel.kt | 6 +++--- .../yapp/ndgl/feature/travel/navigation/TravelEntry.kt | 8 +++++++- .../src/main/java/com/yapp/ndgl/navigation/Route.kt | 3 +++ 10 files changed, 28 insertions(+), 19 deletions(-) diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/component/TravelTemplate.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/component/TravelTemplate.kt index 687f802a..022b0d6a 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/component/TravelTemplate.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/component/TravelTemplate.kt @@ -27,14 +27,14 @@ import com.yapp.ndgl.feature.home.model.TravelContent @Composable internal fun TravelTemplate( travel: TravelContent, - onTravelTemplateClick: (Long) -> Unit, + onTravelTemplateClick: (Long, Int) -> Unit, modifier: Modifier = Modifier, ) { Row( modifier = modifier .fillMaxWidth() .clip(RoundedCornerShape(4.dp)) - .clickable(onClick = { onTravelTemplateClick(travel.travelId) }) + .clickable(onClick = { onTravelTemplateClick(travel.travelId, travel.days) }) .padding(4.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), verticalAlignment = Alignment.Top, diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/PopularTravelSection.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/PopularTravelSection.kt index 936ca9b1..a11f06cc 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/PopularTravelSection.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/main/PopularTravelSection.kt @@ -143,7 +143,7 @@ private fun PopularTravelItem( ) { TravelTemplate( travel = travel, - onTravelTemplateClick = { travelId -> onTravelClick(travelId, travel.days) }, + onTravelTemplateClick = { travelId, _ -> onTravelClick(travelId, travel.days) }, ) } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt index 96c31c56..582b6bf6 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListContract.kt @@ -34,7 +34,7 @@ data class PopularTravelListState( sealed interface PopularTravelListIntent : UiIntent { data object ClickSearchTravelTemplate : PopularTravelListIntent data class SelectPopularTravelTab(val index: Int) : PopularTravelListIntent - data class ClickTravel(val travelId: Long) : PopularTravelListIntent + data class ClickTravel(val travelId: Long, val days: Int) : PopularTravelListIntent } sealed interface PopularTravelListSideEffect : UiSideEffect { diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt index cdc57372..ad558016 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListScreen.kt @@ -48,8 +48,8 @@ internal fun PopularTravelListRoute( onTabSelected = { index -> viewModel.onIntent(PopularTravelListIntent.SelectPopularTravelTab(index)) }, - onTravelClick = { travelId -> - viewModel.onIntent(PopularTravelListIntent.ClickTravel(travelId)) + onTravelClick = { travelId, days -> + viewModel.onIntent(PopularTravelListIntent.ClickTravel(travelId, days)) }, ) @@ -70,7 +70,7 @@ private fun PopularTravelListScreen( goBack: () -> Unit, onSearchClick: () -> Unit, onTabSelected: (Int) -> Unit, - onTravelClick: (Long) -> Unit, + onTravelClick: (Long, Int) -> Unit, ) { Scaffold( topBar = { diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt index 89e8bdd1..37f2c867 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/popular/PopularTravelListViewModel.kt @@ -109,7 +109,7 @@ class PopularTravelListViewModel @Inject constructor( when (intent) { PopularTravelListIntent.ClickSearchTravelTemplate -> postNavigateToSearchTravelTemplate() is PopularTravelListIntent.SelectPopularTravelTab -> selectTab(intent.index) - is PopularTravelListIntent.ClickTravel -> postNavigateToTravelTemplate(intent.travelId) + is PopularTravelListIntent.ClickTravel -> postNavigateToTravelTemplate(intent.travelId, intent.days) } } @@ -121,11 +121,11 @@ class PopularTravelListViewModel @Inject constructor( reduce { copy(selectedTabIndex = index) } } - private fun postNavigateToTravelTemplate(travelId: Long) { + private fun postNavigateToTravelTemplate(travelId: Long, days: Int) { postSideEffect( PopularTravelListSideEffect.NavigateToFollowTravel( travelId = travelId, - days = 1, + days = days, ), ) } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchContract.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchContract.kt index 8b559cb4..3c173080 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchContract.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchContract.kt @@ -22,7 +22,7 @@ data class TemplateSearchState( sealed interface TemplateSearchIntent : UiIntent { data class UpdateSearchKeyword(val keyword: String) : TemplateSearchIntent data class SearchTemplate(val keyword: String) : TemplateSearchIntent - data class ClickTravelTemplate(val travelId: Long) : TemplateSearchIntent + data class ClickTravelTemplate(val travelId: Long, val days: Int) : TemplateSearchIntent } sealed interface TemplateSearchSideEffect : UiSideEffect { diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt index bd5846c4..9f4810e6 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchScreen.kt @@ -51,8 +51,8 @@ internal fun TemplateSearchRoute( onSearch = { keyword -> viewModel.onIntent(TemplateSearchIntent.SearchTemplate(keyword)) }, - onTravelTemplateClick = { travelId -> - viewModel.onIntent(TemplateSearchIntent.ClickTravelTemplate(travelId)) + onTravelTemplateClick = { travelId, days -> + viewModel.onIntent(TemplateSearchIntent.ClickTravelTemplate(travelId, days)) }, ) @@ -72,7 +72,7 @@ private fun TemplateSearchScreen( onBackClick: () -> Unit, onSearchKeywordChange: (String) -> Unit, onSearch: (String) -> Unit, - onTravelTemplateClick: (Long) -> Unit, + onTravelTemplateClick: (Long, Int) -> Unit, ) { Scaffold( topBar = { @@ -317,7 +317,7 @@ private fun TemplateSearchScreenFilledPreview() { onBackClick = {}, onSearchKeywordChange = {}, onSearch = {}, - onTravelTemplateClick = {}, + onTravelTemplateClick = { _, _ -> }, ) } } diff --git a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchViewModel.kt b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchViewModel.kt index 16cfc528..7af78aa7 100644 --- a/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchViewModel.kt +++ b/feature/home/src/main/java/com/yapp/ndgl/feature/home/search/TemplateSearchViewModel.kt @@ -23,7 +23,7 @@ class TemplateSearchViewModel @Inject constructor( when (intent) { is TemplateSearchIntent.UpdateSearchKeyword -> updateKeyword(intent.keyword) is TemplateSearchIntent.SearchTemplate -> searchTravelTemplates(intent.keyword) - is TemplateSearchIntent.ClickTravelTemplate -> postNavigateToTravelTemplate(intent.travelId) + is TemplateSearchIntent.ClickTravelTemplate -> postNavigateToTravelTemplate(intent.travelId, intent.days) } } @@ -65,11 +65,11 @@ class TemplateSearchViewModel @Inject constructor( } } - private fun postNavigateToTravelTemplate(travelId: Long) { + private fun postNavigateToTravelTemplate(travelId: Long, days: Int) { postSideEffect( TemplateSearchSideEffect.NavigateToFollowTravel( travelId = travelId, - days = 1, + days = days, ), ) } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/navigation/TravelEntry.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/navigation/TravelEntry.kt index 95459b92..ca001dee 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/navigation/TravelEntry.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/navigation/TravelEntry.kt @@ -83,7 +83,7 @@ fun EntryProviderScope.travelEntry(navigator: Navigator) { TravelDetailRoute( viewModel = viewModel, navigateBack = { navigator.goBack() }, - navigateToTravelPlaceDetail = { googlePlaceId, tipContent, alternativePlaces -> + navigateToTravelPlaceDetail = { googlePlaceId, tipContent, alternativePlaces, day, itineraryId -> navigator.navigate( Route.PlaceDetail( googlePlaceId = googlePlaceId, @@ -95,6 +95,9 @@ fun EntryProviderScope.travelEntry(navigator: Navigator) { alternativePlaces = alternativePlaces?.map { RouteAlternativePlace(id = it.id, name = it.name, thumbnail = it.thumbnail, placeType = it.placeType.name) }, + travelId = route.travelId, + day = day, + itineraryId = itineraryId, ), ) }, @@ -125,6 +128,9 @@ fun EntryProviderScope.travelEntry(navigator: Navigator) { googlePlaceId = route.googlePlaceId, tipContent = route.tipContent, alternativePlaces = route.alternativePlaces, + travelId = route.travelId, + day = route.day, + itineraryId = route.itineraryId, ) } PlaceDetailRoute( diff --git a/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt b/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt index 260e7970..22fe6a01 100644 --- a/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt +++ b/navigation/src/main/java/com/yapp/ndgl/navigation/Route.kt @@ -39,6 +39,9 @@ sealed interface Route : NavKey { val googlePlaceId: String, val tipContent: RouteTipContent? = null, val alternativePlaces: List? = null, + val travelId: Long? = null, + val day: Int? = null, + val itineraryId: Long? = null, ) : Route @Serializable From 286fd87d5676a54e929264866fec3e1673953889 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 17:38:48 +0900 Subject: [PATCH 4/9] =?UTF-8?q?[NDGL-118]=20fix:=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20API=20=EB=A7=A4=EA=B0=9C=EB=B3=80=EC=88=98?= =?UTF-8?q?=20=EA=B5=AD=EA=B0=80=20=EC=A0=9C=ED=95=9C=20->=20=EC=A7=80?= =?UTF-8?q?=EC=97=AD=20=ED=8E=B8=ED=96=A5=EC=9C=BC=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../yapp/ndgl/data/travel/repository/PlaceRepository.kt | 8 ++++++-- .../feature/travel/additinerary/AddItineraryViewModel.kt | 6 +++--- 2 files changed, 9 insertions(+), 5 deletions(-) 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 1c903add..12cd6a4e 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 @@ -1,6 +1,8 @@ package com.yapp.ndgl.data.travel.repository +import com.google.android.gms.maps.model.LatLng import com.google.android.libraries.places.api.model.AutocompleteSessionToken +import com.google.android.libraries.places.api.model.CircularBounds import com.google.android.libraries.places.api.net.FindAutocompletePredictionsRequest import com.google.android.libraries.places.api.net.PlacesClient import com.yapp.ndgl.data.core.model.error.HttpResponseException @@ -23,15 +25,17 @@ class PlaceRepository @Inject constructor( ) { private var sessionToken: AutocompleteSessionToken? = null - suspend fun searchKeyword(keyword: String, countryCode: String): SearchKeywordResponse { + suspend fun searchKeyword(keyword: String, representativeLatLng: LatLng): SearchKeywordResponse { if (sessionToken == null) { sessionToken = AutocompleteSessionToken.newInstance() } + val bias = CircularBounds.newInstance(representativeLatLng, 10000.0) val requestBuilder = FindAutocompletePredictionsRequest.builder() .setQuery(keyword) .setSessionToken(sessionToken) - .setCountries(countryCode) + // .setCountries(countryCode) + .setLocationBias(bias) val response = placesClient.findAutocompletePredictions(requestBuilder.build()).await() 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 ccc83187..ab633f21 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 @@ -53,7 +53,7 @@ class AddItineraryViewModel @AssistedInject constructor( val searchResults = suspendRunCatching { placeRepository.searchKeyword( keyword = firstChar, - countryCode = countryCode, + representativeLatLng = state.value.representativeLatLng, ) }.getOrNull()?.results?.take(5) ?: emptyList() @@ -153,7 +153,7 @@ class AddItineraryViewModel @AssistedInject constructor( suspendRunCatching { placeRepository.searchKeyword( keyword = keyword, - countryCode = state.value.countryCode, + representativeLatLng = state.value.representativeLatLng, ) }.onSuccess { response -> val results = response.results.map { result -> @@ -180,7 +180,7 @@ class AddItineraryViewModel @AssistedInject constructor( suspendRunCatching { placeRepository.searchKeyword( keyword = keyword, - countryCode = state.value.countryCode, + representativeLatLng = state.value.representativeLatLng, ) }.onSuccess { response -> val results = response.results.map { result -> From e41e6abdaf4e365d17ab8cdf39c8dfc363be8f76 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 17:39:51 +0900 Subject: [PATCH 5/9] =?UTF-8?q?[NDGL-118]=20feature:=20PlaceDetailScreen?= =?UTF-8?q?=EC=97=90=EC=84=9C=20PlanB=EB=A1=9C=20=EC=9E=A5=EC=86=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=8B=9C=20=EB=A1=9C=EC=A7=81=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/travel/model/ChangePlaceEvent.kt | 9 ++++ .../travel/placedetail/PlaceDetailContract.kt | 1 + .../travel/placedetail/PlaceDetailScreen.kt | 1 + .../placedetail/PlaceDetailViewModel.kt | 45 +++++++++++++------ 4 files changed, 43 insertions(+), 13 deletions(-) create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ChangePlaceEvent.kt diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ChangePlaceEvent.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ChangePlaceEvent.kt new file mode 100644 index 00000000..deaf9108 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/ChangePlaceEvent.kt @@ -0,0 +1,9 @@ +package com.yapp.ndgl.data.travel.model + +data class ChangePlaceEvent( + val travelId: Long, + val itineraryId: Long, + val day: Int, + val oldGooglePlaceId: String, + val newGooglePlaceId: String, +) diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailContract.kt index 251b372e..af73b013 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailContract.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailContract.kt @@ -29,4 +29,5 @@ sealed interface PlaceDetailIntent : UiIntent { sealed interface PlaceDetailSideEffect : UiSideEffect { data class NavigateToBrowser(val url: String) : PlaceDetailSideEffect data class NavigateToAlternativePlaceDetail(val googlePlaceId: String) : PlaceDetailSideEffect + data object NavigateBack : PlaceDetailSideEffect } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailScreen.kt index ae184911..d177e8a8 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailScreen.kt @@ -74,6 +74,7 @@ internal fun PlaceDetailRoute( when (sideEffect) { is PlaceDetailSideEffect.NavigateToBrowser -> context.launchBrowser(sideEffect.url) is PlaceDetailSideEffect.NavigateToAlternativePlaceDetail -> navigateToAlternativePlaceDetail(sideEffect.googlePlaceId) + is PlaceDetailSideEffect.NavigateBack -> navigateBack() } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt index 70ce2607..8f552c3f 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/placedetail/PlaceDetailViewModel.kt @@ -21,10 +21,14 @@ import kotlinx.coroutines.launch @HiltViewModel(assistedFactory = PlaceDetailViewModel.Factory::class) class PlaceDetailViewModel @AssistedInject constructor( - @Assisted private val googlePlaceId: String, - @Assisted private val tipContent: RouteTipContent?, - @Assisted private val alternativePlaces: List?, + @Assisted("googlePlaceId") private val googlePlaceId: String, + @Assisted("tipContent") private val tipContent: RouteTipContent?, + @Assisted("alternativePlaces") private val alternativePlaces: List?, + @Assisted("travelId") private val travelId: Long?, + @Assisted("day") private val day: Int?, + @Assisted("itineraryId") private val itineraryId: Long?, private val placeRepository: PlaceRepository, + private val userTravelRepository: com.yapp.ndgl.data.travel.repository.UserTravelRepository, ) : BaseViewModel( initialState = PlaceDetailState(), ) { @@ -110,13 +114,25 @@ class PlaceDetailViewModel @AssistedInject constructor( reduce { copy(selectedAlternativePlace = alternativePlace, showChangeModal = true) } } - // TODO("Plan B 장소 변경 로직") - private fun confirmChangePlace() { - reduce { - copy( - showChangeModal = false, - ) - } + private fun confirmChangePlace() = viewModelScope.launch { + val selectedPlace = state.value.selectedAlternativePlace ?: return@launch + val currentTravelId = travelId ?: return@launch + val currentDay = day ?: return@launch + val currentItineraryId = itineraryId ?: return@launch + + reduce { copy(showChangeModal = false) } + + userTravelRepository.emitChangePlaceEvent( + com.yapp.ndgl.data.travel.model.ChangePlaceEvent( + travelId = currentTravelId, + day = currentDay, + itineraryId = currentItineraryId, + oldGooglePlaceId = googlePlaceId, + newGooglePlaceId = selectedPlace.id, + ), + ) + + postSideEffect(PlaceDetailSideEffect.NavigateBack) } private fun dismissChangeModal() { @@ -126,9 +142,12 @@ class PlaceDetailViewModel @AssistedInject constructor( @AssistedFactory interface Factory { fun create( - googlePlaceId: String, - tipContent: RouteTipContent?, - alternativePlaces: List?, + @Assisted("googlePlaceId") googlePlaceId: String, + @Assisted("tipContent") tipContent: RouteTipContent?, + @Assisted("alternativePlaces") alternativePlaces: List?, + @Assisted("travelId") travelId: Long?, + @Assisted("day") day: Int?, + @Assisted("itineraryId") itineraryId: Long?, ): PlaceDetailViewModel } } From 6287c38c037adcfc742ed5c03892423692b1e2b9 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 17:40:31 +0900 Subject: [PATCH 6/9] =?UTF-8?q?[NDGL-118]=20feature:=20=EC=9E=A5=EC=86=8C?= =?UTF-8?q?=20=EC=83=81=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20=EC=9D=91=EB=8B=B5?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20regularOpeningHours=EB=A5=BC=20placeInf?= =?UTF-8?q?o=20openingHours=20=ED=95=84=EB=93=9C=EB=A1=9C=20=EB=B0=94?= =?UTF-8?q?=EA=BF=94=EC=A3=BC=EB=8A=94=20=ED=99=95=EC=9E=A5=ED=95=A8?= =?UTF-8?q?=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ndgl/feature/travel/model/PlaceInfo.kt | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt index 5a4306a6..b374ff85 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt @@ -2,6 +2,9 @@ package com.yapp.ndgl.feature.travel.model import com.yapp.ndgl.core.util.formatDecimal import com.yapp.ndgl.data.travel.model.PlaceDetailResponse +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.format.DateTimeFormatter import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -59,3 +62,25 @@ fun PlaceDetailResponse.toPlaceInfo(): PlaceInfo { websiteUrl = place.websiteUri, ) } +fun List?.toOpeningHours(startDate: String, day: Int): String? { + if (this.isNullOrEmpty() || startDate.isBlank()) return null + + val targetDayOfWeek = runCatching { + val travelStartDate = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) + val targetDate = travelStartDate.plusDays((day - 1).toLong()) + targetDate.dayOfWeek + }.getOrNull() ?: return null + + val dayOfWeekName = when (targetDayOfWeek) { + DayOfWeek.MONDAY -> "월요일" + DayOfWeek.TUESDAY -> "화요일" + DayOfWeek.WEDNESDAY -> "수요일" + DayOfWeek.THURSDAY -> "목요일" + DayOfWeek.FRIDAY -> "금요일" + DayOfWeek.SATURDAY -> "토요일" + DayOfWeek.SUNDAY -> "일요일" + } + + val openingHour = this.find { it.startsWith(dayOfWeekName) } + return openingHour?.substringAfter(":")?.trim() +} From 783b8292e2bb5109732926248d3aa549cf665599 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 17:41:35 +0900 Subject: [PATCH 7/9] =?UTF-8?q?[NDGL-118]=20feature:=20=EB=82=B4=20?= =?UTF-8?q?=EC=97=AC=ED=96=89=20=EA=B4=80=EB=A0=A8=20API=20=EC=97=B0?= =?UTF-8?q?=EB=8F=99=20-=20=EB=82=B4=20=EC=97=AC=ED=96=89=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EC=9D=BC=EC=A0=95=20=EC=88=98=EC=A0=95=20-=20?= =?UTF-8?q?=EB=82=B4=20=EC=97=AC=ED=96=89=20=EC=9E=A5=EC=86=8C=20=ED=95=9C?= =?UTF-8?q?=EA=B1=B4=20=EC=B6=94=EA=B0=80=20-=20=EB=82=B4=20=EC=97=AC?= =?UTF-8?q?=ED=96=89=20=EC=9E=A5=EC=86=8C=20=EC=A0=95=EB=B3=B4=20=EC=88=98?= =?UTF-8?q?=EC=A0=95(=EB=A9=94=EB=AA=A8,=20=EB=B9=84=EC=9A=A9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ndgl/data/travel/api/UserTravelApi.kt | 17 + .../data/travel/model/AddItineraryRequest.kt | 18 + .../data/travel/model/AddItineraryResponse.kt | 48 ++ .../data/travel/model/TransportationItem.kt | 9 + .../travel/model/UpdateItineraryRequest.kt | 15 +- .../travel/model/UpdateTravelPlaceRequest.kt | 11 + .../model/UserTravelTemplateItinerary.kt | 3 + .../travel/repository/UserTravelRepository.kt | 43 +- .../feature/travel/model/TransportSegment.kt | 14 +- .../traveldetail/TravelDetailContract.kt | 9 +- .../travel/traveldetail/TravelDetailScreen.kt | 30 +- .../traveldetail/TravelDetailViewModel.kt | 613 +++++++++++------- 12 files changed, 557 insertions(+), 273 deletions(-) create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryResponse.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TransportationItem.kt create mode 100644 data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateTravelPlaceRequest.kt diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/UserTravelApi.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/UserTravelApi.kt index 94aecdd9..035f974d 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/UserTravelApi.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/api/UserTravelApi.kt @@ -1,15 +1,19 @@ package com.yapp.ndgl.data.travel.api import com.yapp.ndgl.data.core.model.BaseResponse +import com.yapp.ndgl.data.travel.model.AddItineraryRequest +import com.yapp.ndgl.data.travel.model.AddItineraryResponse import com.yapp.ndgl.data.travel.model.BulkUpdateStartTimeRequest import com.yapp.ndgl.data.travel.model.UpcomingTravelList import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse import com.yapp.ndgl.data.travel.model.UpdateItineraryRequest +import com.yapp.ndgl.data.travel.model.UpdateTravelPlaceRequest import com.yapp.ndgl.data.travel.model.UserTravelTemplateContentInfo import com.yapp.ndgl.data.travel.model.UserTravelTemplateItinerary import retrofit2.http.Body import retrofit2.http.GET import retrofit2.http.PATCH +import retrofit2.http.POST import retrofit2.http.PUT import retrofit2.http.Path import retrofit2.http.Query @@ -41,6 +45,19 @@ interface UserTravelApi { @Body request: BulkUpdateStartTimeRequest, ): BaseResponse + @PATCH("/api/v1/travels/{id}/itinerary/{userTravelPlaceId}") + suspend fun updateTravelPlace( + @Path("id") id: Long, + @Path("userTravelPlaceId") userTravelPlaceId: Long, + @Body request: UpdateTravelPlaceRequest, + ): BaseResponse + + @POST("/api/v1/travels/{id}/itinerary") + suspend fun addItinerary( + @Path("id") id: Long, + @Body request: AddItineraryRequest, + ): BaseResponse + @PUT("/api/v1/travels/{id}/itinerary") suspend fun updateItinerary( @Path("id") id: Long, diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt new file mode 100644 index 00000000..d6df6426 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt @@ -0,0 +1,18 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AddItineraryRequest( + val googlePlaceId: String, + val day: Int, + val sequence: Int, + val startTime: String? = null, + val estimatedDuration: String? = null, + val memo: String? = null, + @SerialName("budget") + val cost: Int? = null, + val distanceKm: Double? = null, + val transportation: List? = null, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryResponse.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryResponse.kt new file mode 100644 index 00000000..c4188df3 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryResponse.kt @@ -0,0 +1,48 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class AddItineraryResponse( + val id: Long, + val day: Int, + val sequence: Int, + val startTime: String? = null, + val estimatedDuration: Int, + val memo: String? = null, + @SerialName("budget") + val cost: Int? = null, + val distanceKm: Double? = null, + val transportation: List? = null, + val travelerTips: List? = null, + val planB: List? = null, + val place: ItineraryPlace, +) { + @Serializable + data class Transportation( + val mode: TransportCategory, + val timeMin: Int, + ) + + @Serializable + data class PlanBPlace( + val googlePlaceId: String, + val name: String, + val thumbnail: String? = null, + val category: PlaceCategory, + ) + + @Serializable + data class ItineraryPlace( + val googlePlaceId: String, + val thumbnail: String? = null, + val latitude: Double, + val longitude: Double, + val name: String, + val regularOpeningHours: String? = null, + val googleMapsUri: String? = null, + val category: PlaceCategory, + val priceRange: String? = null, + ) +} diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TransportationItem.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TransportationItem.kt new file mode 100644 index 00000000..8ffc7380 --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/TransportationItem.kt @@ -0,0 +1,9 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.Serializable + +@Serializable +data class TransportationItem( + val mode: TransportCategory, + val timeMin: Int, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateItineraryRequest.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateItineraryRequest.kt index e9423a30..07958df4 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateItineraryRequest.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateItineraryRequest.kt @@ -10,19 +10,14 @@ data class UpdateItineraryRequest( @Serializable data class ItineraryUpdateItem( - val placeId: Long, + val googlePlaceId: String, val day: Int, val sequence: Int, - val startTime: String, + val startTime: String? = null, val estimatedDuration: Int, - @SerialName("travelerTip") - val memo: String?, + val memo: String? = null, + @SerialName("budget") + val cost: Int? = null, val distanceKm: Double? = null, val transportation: List? = null, ) - -@Serializable -data class TransportationItem( - val mode: TransportCategory, - val timeMin: Int, -) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateTravelPlaceRequest.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateTravelPlaceRequest.kt new file mode 100644 index 00000000..cbefbc3d --- /dev/null +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpdateTravelPlaceRequest.kt @@ -0,0 +1,11 @@ +package com.yapp.ndgl.data.travel.model + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateTravelPlaceRequest( + val memo: String? = null, + @SerialName("budget") + val cost: Int? = null, +) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UserTravelTemplateItinerary.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UserTravelTemplateItinerary.kt index 3a116560..d996bcad 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UserTravelTemplateItinerary.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UserTravelTemplateItinerary.kt @@ -1,5 +1,6 @@ package com.yapp.ndgl.data.travel.model +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -14,6 +15,8 @@ data class UserTravelTemplateItinerary( val startTime: String? = null, val estimatedDuration: Int, val memo: String? = null, + @SerialName("budget") + val cost: Int? = null, val distanceKm: Double? = null, val transportation: List? = null, val travelerTips: List? = null, diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt index d0a7c149..ee92716d 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt @@ -6,8 +6,10 @@ import com.yapp.ndgl.data.travel.api.UserTravelApi import com.yapp.ndgl.data.travel.model.AddItineraryRequest import com.yapp.ndgl.data.travel.model.AddPlaceEvent import com.yapp.ndgl.data.travel.model.BulkUpdateStartTimeRequest +import com.yapp.ndgl.data.travel.model.ChangePlaceEvent import com.yapp.ndgl.data.travel.model.ItineraryUpdateItem import com.yapp.ndgl.data.travel.model.StartTimeUpdateItem +import com.yapp.ndgl.data.travel.model.TransportationItem import com.yapp.ndgl.data.travel.model.TravelCreatedEvent import com.yapp.ndgl.data.travel.model.UpcomingTravelList import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse @@ -41,6 +43,13 @@ class UserTravelRepository @Inject constructor( ) val travelCreatedEvent: SharedFlow = _travelCreatedEvent.asSharedFlow() + private val _changePlaceEvent = MutableSharedFlow( + replay = 0, + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val changePlaceEvent: SharedFlow = _changePlaceEvent.asSharedFlow() + suspend fun emitAddPlaceEvent(event: AddPlaceEvent) { _addPlaceEvent.emit(event) } @@ -49,6 +58,10 @@ class UserTravelRepository @Inject constructor( _travelCreatedEvent.emit(event) } + suspend fun emitChangePlaceEvent(event: ChangePlaceEvent) { + _changePlaceEvent.emit(event) + } + suspend fun getUpcomingTravel(): UpcomingTravelResponse? { return try { userTravelApi.getUpcomingTravel().getData() @@ -128,18 +141,20 @@ class UserTravelRepository @Inject constructor( estimatedDuration: String? = null, cost: Int? = null, memo: String? = null, - ) { - userTravelApi.addItinerary( - id = travelId, - request = AddItineraryRequest( - googlePlaceId = googlePlaceId, - day = day, - sequence = sequence, - startTime = startTime, - estimatedDuration = estimatedDuration, - cost = cost, - memo = memo, - ), - ).getData() - } + distanceKm: Double? = null, + transportation: List? = null, + ) = userTravelApi.addItinerary( + id = travelId, + request = AddItineraryRequest( + googlePlaceId = googlePlaceId, + day = day, + sequence = sequence, + startTime = startTime, + estimatedDuration = estimatedDuration, + cost = cost, + memo = memo, + distanceKm = distanceKm, + transportation = transportation, + ), + ).getData() } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt index 0f8010e9..e55509be 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt @@ -1,14 +1,19 @@ package com.yapp.ndgl.feature.travel.model +import com.yapp.ndgl.data.travel.model.TransportationItem import java.util.Locale.getDefault +import kotlin.math.roundToInt import kotlin.time.Duration data class TransportSegment( val googlePlaceId: String, val type: TransportType, - val duration: Duration, + val duration: Duration, // m 단위 val distance: Int, ) { + val distanceKm: Double + get() = (distance / 100.0).roundToInt() / 10.0 + fun formatDistance(): String { return when { distance >= 1000 -> { @@ -23,4 +28,11 @@ data class TransportSegment( else -> "${distance}m" } } + + fun toTransportationItem(): TransportationItem { + return TransportationItem( + mode = type.toTransportCategory(), + timeMin = duration.inWholeMinutes.toInt(), + ) + } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt index e9c36fd6..3f71dba8 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt @@ -15,6 +15,8 @@ import kotlin.time.Duration.Companion.hours data class TravelDetailState( val contentInfo: ContentInfo = ContentInfo(), val countryCode: String = "", + val creatorName: String = "", + val startDate: String = "", val days: Int = 1, val selectedDay: Int = 1, val itineraries: List = emptyList(), @@ -45,7 +47,7 @@ data class TravelDetailState( val representativeLatLng: LatLng get() { val currentDayPlaces = itineraries.getOrNull(selectedDay - 1)?.places - val firstPlaceInSelectedDay = currentDayPlaces?.firstOrNull() + val firstPlaceInSelectedDay = currentDayPlaces?.lastOrNull() if (firstPlaceInSelectedDay != null) { return LatLng(firstPlaceInSelectedDay.placeInfo.latitude, firstPlaceInSelectedDay.placeInfo.longitude) @@ -54,7 +56,7 @@ data class TravelDetailState( return itineraries .flatMap { it.places } .firstOrNull() - ?.let { LatLng(it.placeInfo.latitude, it.placeInfo.longitude) } ?: LatLng(37.5665, 126.9780) // 모든 일차가 비어 있다면 '서울' 좌표 반환 + ?.let { LatLng(it.placeInfo.latitude, it.placeInfo.longitude) } ?: LatLng(37.5665, 126.9780) // FIXME: 모든 일차가 비어 있다면 '서울' 좌표 반환 } // 헤더(0) + stickyHeader(1) + 맵 아이템(2) = 3개가 장소 아이템 앞에 위치 @@ -138,6 +140,8 @@ sealed interface TravelDetailSideEffect : UiSideEffect { val googlePlaceId: String, val tipContent: TipContent?, val alternativePlaces: List?, + val day: Int, + val itineraryId: Long, ) : TravelDetailSideEffect data class NavigateToBrowser(val url: String) : TravelDetailSideEffect @@ -150,4 +154,5 @@ sealed interface TravelDetailSideEffect : UiSideEffect { data object NavigateToMyTravel : TravelDetailSideEffect data class ScrollToPlace(val placeId: Long) : TravelDetailSideEffect + data class AnimatePlaceChange(val googlePlaceId: String) : TravelDetailSideEffect } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt index 20312e5a..2bc69e89 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt @@ -99,7 +99,7 @@ import kotlin.time.Duration.Companion.minutes internal fun TravelDetailRoute( viewModel: TravelDetailViewModel = hiltViewModel(), navigateBack: () -> Unit, - navigateToTravelPlaceDetail: (String, TipContent?, List?) -> Unit, + navigateToTravelPlaceDetail: (String, TipContent?, List?, Int, Long) -> Unit, navigateToAddItinerary: (travelId: Long, day: Int, country: String, representativeLatitude: Double, representativeLongitude: Double) -> Unit, ) { @@ -112,7 +112,13 @@ internal fun TravelDetailRoute( when (sideEffect) { is TravelDetailSideEffect.NavigateBack -> navigateBack() is TravelDetailSideEffect.NavigateToTravelPlaceDetail -> { - navigateToTravelPlaceDetail(sideEffect.googlePlaceId, sideEffect.tipContent, sideEffect.alternativePlaces) + navigateToTravelPlaceDetail( + sideEffect.googlePlaceId, + sideEffect.tipContent, + sideEffect.alternativePlaces, + sideEffect.day, + sideEffect.itineraryId, + ) } is TravelDetailSideEffect.NavigateToBrowser -> { @@ -145,6 +151,20 @@ internal fun TravelDetailRoute( } } } + + is TravelDetailSideEffect.AnimatePlaceChange -> { + coroutineScope.launch { + val dayIndex = state.selectedDay - 1 + val places = state.itineraries.getOrNull(dayIndex)?.places.orEmpty() + val placeIndex = places.indexOfFirst { it.placeInfo.googlePlaceId == sideEffect.googlePlaceId } + + if (placeIndex >= 0) { + val targetIndex = state.placesOffset + placeIndex + delay(100) + listState.animateScrollToItem(targetIndex) + } + } + } } } @@ -431,16 +451,18 @@ private fun TravelDetailScreen( } if (index < state.currentPlaces.size - 1) { + Spacer(Modifier.height(10.dp)) + place.transportToNext?.let { segment -> - Spacer(Modifier.height(10.dp)) Box(modifier = Modifier.padding(horizontal = 24.dp)) { TransportSegment( segment = segment, onClick = { clickTransportSegment(place) }, ) } - Spacer(Modifier.height(10.dp)) } + + Spacer(Modifier.height(10.dp)) } } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt index 4a5b4b7c..a47a7ae1 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt @@ -6,12 +6,13 @@ import com.yapp.ndgl.core.util.parseDurationToTimeString import com.yapp.ndgl.core.util.parseTimeStringToDuration import com.yapp.ndgl.core.util.suspendRunCatching import com.yapp.ndgl.data.travel.model.AddPlaceEvent +import com.yapp.ndgl.data.travel.model.ChangePlaceEvent import com.yapp.ndgl.data.travel.model.ItineraryUpdateItem import com.yapp.ndgl.data.travel.model.StartTimeUpdateItem -import com.yapp.ndgl.data.travel.model.TransportationItem import com.yapp.ndgl.data.travel.model.TravelMode import com.yapp.ndgl.data.travel.model.UserTravelTemplateContentInfo import com.yapp.ndgl.data.travel.model.UserTravelTemplateItinerary +import com.yapp.ndgl.data.travel.repository.PlaceRepository import com.yapp.ndgl.data.travel.repository.RouteRepository import com.yapp.ndgl.data.travel.repository.UserTravelRepository import com.yapp.ndgl.feature.travel.model.AlternativePlace @@ -22,8 +23,9 @@ import com.yapp.ndgl.feature.travel.model.TipContent import com.yapp.ndgl.feature.travel.model.TransportSegment import com.yapp.ndgl.feature.travel.model.TransportType import com.yapp.ndgl.feature.travel.model.VideoInfo +import com.yapp.ndgl.feature.travel.model.toOpeningHours +import com.yapp.ndgl.feature.travel.model.toPlaceInfo import com.yapp.ndgl.feature.travel.model.toPlaceType -import com.yapp.ndgl.feature.travel.model.toTransportCategory import com.yapp.ndgl.feature.travel.model.toTransportType import com.yapp.ndgl.feature.travel.model.toTravelMode import dagger.assisted.Assisted @@ -47,6 +49,7 @@ class TravelDetailViewModel @AssistedInject constructor( @Assisted private val days: Int, private val userTravelRepository: UserTravelRepository, private val routeRepository: RouteRepository, + private val placeRepository: PlaceRepository, ) : BaseViewModel( initialState = TravelDetailState(days = days), ) { @@ -54,6 +57,7 @@ class TravelDetailViewModel @AssistedInject constructor( loadUserTravelTemplateItinerary() loadUserTravelTemplateContentInfo() subscribeToAddPlaceEvent() + subscribeToChangePlaceEvent() } private fun loadUserTravelTemplateItinerary() = viewModelScope.launch { @@ -84,6 +88,8 @@ class TravelDetailViewModel @AssistedInject constructor( copy( contentInfo = info.toContentInfo(), countryCode = info.countryCode, + creatorName = info.program.creatorName, + startDate = info.startDate, ) } }.onFailure { @@ -99,133 +105,157 @@ class TravelDetailViewModel @AssistedInject constructor( } } + private fun subscribeToChangePlaceEvent() = viewModelScope.launch { + userTravelRepository.changePlaceEvent.collect { event -> + if (event.travelId == travelId) { + handleChangePlace(event) + } + } + } + private suspend fun handleAddPlace(event: AddPlaceEvent) { val dayIndex = event.day - 1 val currentItinerary = state.value.itineraries.getOrNull(dayIndex) ?: return - val newSequence = currentItinerary.places.size + 1 - - val newPlaceInfo = PlaceInfo( - googlePlaceId = event.googlePlaceId, - name = event.name, - placeType = event.placeType.toPlaceType(), - day = event.day, - sequence = newSequence, - thumbnail = event.thumbnail, - latitude = event.latitude, - longitude = event.longitude, - address = event.address, - phoneNumber = event.phoneNumber, - googleMapsUri = event.googleMapsUri, - websiteUrl = event.websiteUrl, - rating = event.rating, - userRatingCount = event.userRatingCount, - estimatedDuration = event.estimatedDuration.minutes, - ) - - val newPlace = TravelPlace( - id = event.googlePlaceId.hashCode().toLong(), // FIXME: 임시 ID (googlePlaceId 해시), API 응답에서 실제 itinerary item ID 필요 - placeInfo = newPlaceInfo, - regularOpeningHours = null, - userData = TravelPlace.UserData( - estimatedDuration = event.estimatedDuration.minutes, - ), - startTime = 0.hours, - transportToNext = null, + val lastPlace = currentItinerary.places.last() + + // 추가된 장소로 가는 교통수단 계산 + val newTransportSegment = computeRoute( + originLatitude = lastPlace.placeInfo.latitude, + originLongitude = lastPlace.placeInfo.longitude, + destinationLatitude = event.latitude, + destinationLongitude = event.longitude, + newGooglePlaceId = event.googlePlaceId, + travelMode = TravelMode.TRANSIT, ) - - val updatedPlaces = if (currentItinerary.places.isNotEmpty()) { - val lastPlace = currentItinerary.places.last() - val transportSegment = calculateTransport(lastPlace, newPlace) - - val placesWithTransport = currentItinerary.places.dropLast(1) + - lastPlace.copy(transportToNext = transportSegment) - placesWithTransport + newPlace + val (distanceKm, transportation) = if (currentItinerary.places.isNotEmpty()) { + newTransportSegment?.distanceKm to listOfNotNull(newTransportSegment?.toTransportationItem()) } else { - listOf(newPlace) + null to null } - val firstPlaceStartTime = currentItinerary.places.firstOrNull()?.startTime - ?: Itinerary.DEFAULT_START_TIME.hours - val timedPlaces = calculatePlaceStartTimes(updatedPlaces, firstPlaceStartTime) + suspendRunCatching { + userTravelRepository.addItinerary( + travelId = travelId, + googlePlaceId = event.googlePlaceId, + day = event.day, + sequence = newSequence, + startTime = null, + estimatedDuration = "${event.estimatedDuration}", + cost = null, + memo = null, + distanceKm = distanceKm, + transportation = transportation, + ) + }.onSuccess { response -> + val newPlace = TravelPlace( + id = response.id, + placeInfo = PlaceInfo( + googlePlaceId = response.place.googlePlaceId, + name = response.place.name, + placeType = response.place.category.toPlaceType(), + day = response.day, + sequence = response.sequence, + thumbnail = response.place.thumbnail, + latitude = response.place.latitude, + longitude = response.place.longitude, + googleMapsUri = response.place.googleMapsUri, + estimatedDuration = response.estimatedDuration.minutes, + ), + regularOpeningHours = response.place.regularOpeningHours, + userData = TravelPlace.UserData( + estimatedDuration = response.estimatedDuration.minutes, + ), + startTime = lastPlace.startTime + lastPlace.placeInfo.estimatedDuration + (newTransportSegment?.duration ?: 0.hours), + transportToNext = null, + ) - reduce { - val updatedItineraries = itineraries.mapIndexed { index, itinerary -> - if (index == dayIndex) { - itinerary.copy(places = timedPlaces) + val updatedPlaces = if (currentItinerary.places.isNotEmpty()) { + val lastPlace = currentItinerary.places.last() + val transportSegment = if (distanceKm != null && transportation != null) { + TransportSegment( + googlePlaceId = event.googlePlaceId, + type = TransportType.TRANSIT, + duration = (transportation.first().timeMin * 60).seconds, + distance = (distanceKm * 1000).toInt(), + ) } else { - itinerary + null } + val placesWithTransport = currentItinerary.places.dropLast(1) + lastPlace.copy(transportToNext = transportSegment) + placesWithTransport + newPlace + } else { + listOf(newPlace) } - copy(itineraries = updatedItineraries) - } - postSideEffect(TravelDetailSideEffect.ScrollToPlace(newPlace.id)) - // FIXME: 일정 추가 API 연동 + reduce { + val updatedItineraries = itineraries.mapIndexed { index, itinerary -> + if (index == dayIndex) { + itinerary.copy(places = updatedPlaces) + } else { + itinerary + } + } + copy(itineraries = updatedItineraries) + } + + postSideEffect(TravelDetailSideEffect.ScrollToPlace(newPlace.id)) + }.onFailure { + // TODO: Handle API failure + } } - private suspend fun calculateTransport( - from: TravelPlace, - to: TravelPlace, - ): TransportSegment? { - // FIXME: 기획상 변경될 수 있음, 현재는 대중교통 고정 - return suspendRunCatching { - routeRepository.computeRoute( - originLatitude = from.placeInfo.latitude, - originLongitude = from.placeInfo.longitude, - destinationLatitude = to.placeInfo.latitude, - destinationLongitude = to.placeInfo.longitude, - travelMode = TravelMode.TRANSIT, - ) - }.getOrNull()?.let { routeInfo -> - if (routeInfo.distanceMeters > 0) { - TransportSegment( - googlePlaceId = to.placeInfo.googlePlaceId, - type = TransportType.TRANSIT, - duration = routeInfo.duration.removeSuffix("s").toInt().seconds, - distance = routeInfo.distanceMeters, - ) + private suspend fun handleChangePlace(event: ChangePlaceEvent) { + val dayIndex = event.day - 1 + val currentItinerary = state.value.itineraries.getOrNull(dayIndex) ?: return + val targetPlace = currentItinerary.places.find { it.id == event.itineraryId } ?: return + val targetIndex = currentItinerary.places.indexOf(targetPlace) + + val newPlaceResponse = suspendRunCatching { + placeRepository.getPlace(event.newGooglePlaceId) + }.getOrNull() ?: return + + val newPlaceInfo = newPlaceResponse.toPlaceInfo().copy( + day = targetPlace.placeInfo.day, + sequence = targetPlace.placeInfo.sequence, + tipContent = null, // 장소 변경 시 꿀팁 제거 + alternativePlaces = null, // 장소 변경 시 대체 장소(planB) 제거 + ) + val newRegularOpeningHours = newPlaceResponse.place.regularOpeningHours?.toOpeningHours( + startDate = state.value.startDate, + day = event.day, + ) + val replacedPlace = targetPlace.copy( + placeInfo = newPlaceInfo, + regularOpeningHours = newRegularOpeningHours, + transportToNext = null, // 장소 변경 시 교통수단 재계산 필요 + ) + val updatedPlaces = currentItinerary.places.mapIndexed { index, place -> + if (index == targetIndex) { + replacedPlace } else { - null + place } } - } + val recalculatedPlaces = recalculateTransportSegments(updatedPlaces) + val firstPlaceStartTime = currentItinerary.places.firstOrNull()?.startTime ?: Itinerary.DEFAULT_START_TIME.hours + val timedPlaces = calculatePlaceStartTimes(recalculatedPlaces, firstPlaceStartTime) + val updatedItinerary = currentItinerary.copy(places = timedPlaces) + val updatedItineraries = state.value.itineraries.mapIndexed { index, itinerary -> + if (index == dayIndex) updatedItinerary else itinerary + } - override suspend fun handleIntent(intent: TravelDetailIntent) { - when (intent) { - is TravelDetailIntent.SelectDay -> selectDay(intent.day) - is TravelDetailIntent.ClickStartTimeSetting -> clickStartTimeSetting() - is TravelDetailIntent.ClickEditTravel -> clickEditTravel() - is TravelDetailIntent.ClickAddScheduleButton -> clickAddScheduleButton() - is TravelDetailIntent.CheckPlaceItem -> checkPlaceItem(intent.placeId) - is TravelDetailIntent.CheckSelectAll -> checkSelectAll() - is TravelDetailIntent.ClickDeleteSelectedPlaces -> clickDeleteSelectedPlaces() - is TravelDetailIntent.ConfirmDeleteSelectedPlaces -> confirmDeleteSelectedPlaces() - is TravelDetailIntent.DismissDeleteModal -> dismissDeleteModal() - is TravelDetailIntent.ClickBack -> clickBack() - is TravelDetailIntent.ConfirmCancelEditMode -> confirmCancelEditMode() - is TravelDetailIntent.DismissCancelEditModal -> dismissCancelEditModal() - is TravelDetailIntent.LongClickPlaceItem -> longClickPlaceItem() - is TravelDetailIntent.DismissStartTimeSettingBottomSheet -> dismissStartTimeSettingBottomSheet() - is TravelDetailIntent.ConfirmStartTimeSetting -> confirmStartTimeSetting(intent.startTime) - is TravelDetailIntent.ReorderPlaces -> reorderPlaces(intent.dayIndex, intent.fromIndex, intent.toIndex) - is TravelDetailIntent.ConfirmEditMode -> confirmEditMode() - is TravelDetailIntent.ClickTransportSegment -> clickTransportSegment(intent.place) - is TravelDetailIntent.DismissTransportBottomSheet -> dismissTransportBottomSheet() - is TravelDetailIntent.ConfirmChangeTransportSegment -> confirmChangeTransportSegment(intent.segment) - is TravelDetailIntent.ClickPlaceItem -> clickPlaceItem(intent.place) - is TravelDetailIntent.DismissPlaceBottomSheet -> dismissPlaceBottomSheet() - is TravelDetailIntent.NavigateToTravelPlaceDetail -> navigateToPlaceDetail(intent.placeId) - is TravelDetailIntent.ClickAddTime -> clickAddTime() - is TravelDetailIntent.ClickAddCost -> clickAddCost() - is TravelDetailIntent.ClickAddMemo -> clickAddMemo() - is TravelDetailIntent.ClickFindRoute -> clickFindRoute(intent.googleMapsUri) - is TravelDetailIntent.DismissTimeBottomSheet -> dismissTimeBottomSheet() - is TravelDetailIntent.ConfirmDuration -> confirmDuration(intent.duration) - is TravelDetailIntent.DismissCostModal -> dismissCostModal() - is TravelDetailIntent.ConfirmCost -> confirmCost(intent.cost) - is TravelDetailIntent.DismissMemoModal -> dismissMemoModal() - is TravelDetailIntent.ConfirmMemo -> confirmMemo(intent.memo) + updateItinerary(updatedItineraries).onSuccess { + reduce { + val updatedItineraries = itineraries.mapIndexed { index, itinerary -> + if (index == dayIndex) updatedItinerary else itinerary + } + copy(itineraries = updatedItineraries) + } + + postSideEffect(TravelDetailSideEffect.AnimatePlaceChange(event.newGooglePlaceId)) + }.onFailure { + // TODO: 에러 처리 } } @@ -436,17 +466,17 @@ class TravelDetailViewModel @AssistedInject constructor( } } - // 낙관적 업데이트: UI 먼저 업데이트 - reduce { - copy( - itineraries = updatedItineraries, - isEditMode = false, - selectedPlaceIds = emptySet(), - ) + updateItinerary(updatedItineraries).onSuccess { + reduce { + copy( + itineraries = updatedItineraries, + isEditMode = false, + selectedPlaceIds = emptySet(), + ) + } + }.onFailure { + // TODO: Handle failure } - - // FIXME: API 실패 시 롤백 로직 필요 - updateItinerary() } private fun clickTransportSegment(place: TravelPlace) = viewModelScope.launch { @@ -508,18 +538,18 @@ class TravelDetailViewModel @AssistedInject constructor( } } - // 낙관적 업데이트: UI 먼저 업데이트 - reduce { - copy( - itineraries = updatedItineraries, - selectedPlace = null, - showTransportBottomSheet = false, - availableTransports = emptyList(), - ) + updateItinerary(updatedItineraries).onSuccess { + reduce { + copy( + itineraries = updatedItineraries, + selectedPlace = null, + showTransportBottomSheet = false, + availableTransports = emptyList(), + ) + } + }.onFailure { + // TODO: Handle failure } - - // FIXME: API 실패 시 롤백 로직 필요 - updateItinerary() } private fun dismissTransportBottomSheet() { @@ -556,9 +586,11 @@ class TravelDetailViewModel @AssistedInject constructor( TravelDetailSideEffect.NavigateToTravelPlaceDetail( googlePlaceId = googlePlaceId, tipContent = place.placeInfo.tipContent?.let { - TipContent(creatorName = it.creatorName, tips = it.tips) + TipContent(creatorName = state.value.creatorName, tips = it.tips) }, alternativePlaces = place.placeInfo.alternativePlaces, + day = place.placeInfo.day, + itineraryId = place.id, ), ) reduce { @@ -593,22 +625,20 @@ class TravelDetailViewModel @AssistedInject constructor( } } val firstPlaceStartTime = itinerary.places.firstOrNull()?.startTime ?: Itinerary.DEFAULT_START_TIME.hours - itinerary.copy( - places = calculatePlaceStartTimes(durationUpdatedPlaces, firstPlaceStartTime), - ) + itinerary.copy(places = calculatePlaceStartTimes(durationUpdatedPlaces, firstPlaceStartTime)) } - // 낙관적 업데이트: UI 먼저 업데이트 - reduce { - copy( - itineraries = updatedItineraries, - selectedPlace = null, - showTimeBottomSheet = false, - ) + updateItinerary(updatedItineraries).onSuccess { + reduce { + copy( + itineraries = updatedItineraries, + selectedPlace = null, + showTimeBottomSheet = false, + ) + } + }.onFailure { + // TODO: Handle failure } - - // FIXME: API 실패 시 롤백 로직 필요 - updateItinerary() } private fun clickAddCost() { @@ -621,28 +651,41 @@ class TravelDetailViewModel @AssistedInject constructor( reduce { copy(showCostModal = false) } } - // FIXME : 비용 추가 관련 API 미제작 - private fun confirmCost(cost: Int) { - reduce { - var updatedPlace: TravelPlace? = null - val updatedItineraries = itineraries.map { itinerary -> - itinerary.copy( - places = itinerary.places.map { place -> - if (place.id == selectedPlace?.id) { - val updated = place.copy(userData = place.userData.copy(cost = cost)) - updatedPlace = updated - updated - } else { - place - } - }, + private fun confirmCost(cost: Int) = viewModelScope.launch { + val selectedPlace = state.value.selectedPlace ?: return@launch + + suspendRunCatching { + userTravelRepository.updateTravelPlace( + travelId = travelId, + userTravelPlaceId = selectedPlace.id, + cost = cost, + memo = selectedPlace.userData.memo, + ) + }.onSuccess { + reduce { + var updatedPlace: TravelPlace? = null + val updatedItineraries = itineraries.map { itinerary -> + itinerary.copy( + places = itinerary.places.map { place -> + if (place.id == selectedPlace.id) { + val updated = place.copy(userData = place.userData.copy(cost = cost)) + updatedPlace = updated + updated + } else { + place + } + }, + ) + } + copy( + itineraries = updatedItineraries, + selectedPlace = updatedPlace, + showCostModal = false, ) } - copy( - itineraries = updatedItineraries, - selectedPlace = updatedPlace, - showCostModal = false, - ) + }.onFailure { + // TODO: Handle failure + reduce { copy(showCostModal = false) } } } @@ -656,28 +699,41 @@ class TravelDetailViewModel @AssistedInject constructor( reduce { copy(showMemoModal = false) } } - // FIXME: 메모 추가 관련 API 미제작 - private fun confirmMemo(memo: String) { - reduce { - var updatedPlace: TravelPlace? = null - val updatedItineraries = itineraries.map { itinerary -> - itinerary.copy( - places = itinerary.places.map { place -> - if (place.id == selectedPlace?.id) { - val updated = place.copy(userData = place.userData.copy(memo = memo.trim())) - updatedPlace = updated - updated - } else { - place - } - }, + private fun confirmMemo(memo: String) = viewModelScope.launch { + val selectedPlace = state.value.selectedPlace ?: return@launch + + suspendRunCatching { + userTravelRepository.updateTravelPlace( + travelId = travelId, + userTravelPlaceId = selectedPlace.id, + cost = selectedPlace.userData.cost, + memo = memo.trim(), + ) + }.onSuccess { + reduce { + var updatedPlace: TravelPlace? = null + val updatedItineraries = itineraries.map { itinerary -> + itinerary.copy( + places = itinerary.places.map { place -> + if (place.id == selectedPlace.id) { + val updated = place.copy(userData = place.userData.copy(memo = memo.trim())) + updatedPlace = updated + updated + } else { + place + } + }, + ) + } + copy( + itineraries = updatedItineraries, + selectedPlace = updatedPlace, + showMemoModal = false, ) } - copy( - itineraries = updatedItineraries, - selectedPlace = updatedPlace, - showMemoModal = false, - ) + }.onFailure { + // TODO: Handle failure + reduce { copy(showMemoModal = false) } } } @@ -703,6 +759,15 @@ class TravelDetailViewModel @AssistedInject constructor( val transportOptions = travelModes.map { mode -> async { + computeRoute( + originLatitude = from.placeInfo.latitude, + originLongitude = from.placeInfo.longitude, + destinationLatitude = to.placeInfo.latitude, + destinationLongitude = to.placeInfo.longitude, + newGooglePlaceId = to.placeInfo.googlePlaceId, + travelMode = mode, + ) + suspendRunCatching { routeRepository.computeRoute( originLatitude = from.placeInfo.latitude, @@ -729,78 +794,83 @@ class TravelDetailViewModel @AssistedInject constructor( transportOptions.awaitAll().filterNotNull() } - // FIXME: 기획상 변경될 수 있음, 현재는 대중교통 고정 - private suspend fun recalculateTransportSegments(places: List): List { - return places.mapIndexed { index, place -> - val nextPlace = places.getOrNull(index + 1) - val transportSegment = if (nextPlace != null) { - // googlePlaceId가 일치하면 기존 transportToNext 재사용 + private suspend fun computeRoute( + originLatitude: Double, + originLongitude: Double, + destinationLatitude: Double, + destinationLongitude: Double, + newGooglePlaceId: String, + travelMode: TravelMode, + ): TransportSegment? { + return suspendRunCatching { + routeRepository.computeRoute( + originLatitude = originLatitude, + originLongitude = originLongitude, + destinationLatitude = destinationLatitude, + destinationLongitude = destinationLongitude, + travelMode = travelMode, + ) + }.getOrNull()?.let { routeInfo -> + if (routeInfo.distanceMeters > 0) { + TransportSegment( + googlePlaceId = newGooglePlaceId, + type = travelMode.toTransportType(), + duration = routeInfo.duration.removeSuffix("s").toLong().seconds, + distance = routeInfo.distanceMeters, + ) + } else { + null + } + } + } + + // FIXME: 기획상 변경될 수 있음, 현재는 대중교통, 도보, 자동차, 자전거, 오토바이 순 + private suspend fun recalculateTransportSegments(places: List): List = coroutineScope { + val travelModes = listOf( + TravelMode.TRANSIT, + TravelMode.WALK, + TravelMode.DRIVE, + TravelMode.BICYCLE, + TravelMode.TWO_WHEELER, + ) + + places.mapIndexed { index, place -> + async { + val nextPlace = places.getOrNull(index + 1) ?: return@async place.copy(transportToNext = null) + + // 기존 최적화: 다음 장소 ID가 변하지 않았다면 기존 데이터 유지 if (place.transportToNext?.googlePlaceId == nextPlace.placeInfo.googlePlaceId) { - place.transportToNext + place } else { - // googlePlaceId가 다르면 새로 계산 - suspendRunCatching { - routeRepository.computeRoute( + var newSegment: TransportSegment? = null + for (mode in travelModes) { + val result = computeRoute( originLatitude = place.placeInfo.latitude, originLongitude = place.placeInfo.longitude, destinationLatitude = nextPlace.placeInfo.latitude, destinationLongitude = nextPlace.placeInfo.longitude, - travelMode = TravelMode.TRANSIT, + newGooglePlaceId = nextPlace.placeInfo.googlePlaceId, + travelMode = mode, ) - }.getOrNull()?.let { routeInfo -> - if (routeInfo.distanceMeters > 0) { - TransportSegment( - googlePlaceId = nextPlace.placeInfo.googlePlaceId, - type = TransportType.TRANSIT, - duration = routeInfo.duration.removeSuffix("s").toInt().seconds, - distance = routeInfo.distanceMeters, - ) - } else { - null + + if (result != null) { + newSegment = result + break } } + + place.copy(transportToNext = newSegment) } - } else { - null } - - place.copy(transportToNext = transportSegment) - } + }.awaitAll() } - private fun updateItinerary() = viewModelScope.launch { - val allItineraryItems = state.value.itineraries.flatMapIndexed { dayIndex, itinerary -> - val day = dayIndex + 1 - itinerary.places.map { place -> - ItineraryUpdateItem( - placeId = place.id, - day = day, - sequence = place.placeInfo.sequence, - startTime = place.startTime.parseDurationToTimeString(), - estimatedDuration = place.userData.estimatedDuration.inWholeMinutes.toInt(), - memo = place.userData.memo, - distanceKm = place.transportToNext?.let { it.distance / 1000.0 }, - transportation = place.transportToNext?.let { - listOf( - TransportationItem( - mode = it.type.toTransportCategory(), - timeMin = it.duration.inWholeMinutes.toInt(), - ), - ) - }, - ) - } - } - - suspendRunCatching { + private suspend fun updateItinerary(updatedItineraries: List): Result { + return suspendRunCatching { userTravelRepository.updateItinerary( travelId = travelId, - itineraries = allItineraryItems, + itineraries = updatedItineraries.toUpdateItems(), ) - }.onSuccess { - // FIXME: 성공 처리 - }.onFailure { - // FIXME: 에러 처리 } } @@ -843,6 +913,7 @@ class TravelDetailViewModel @AssistedInject constructor( userData = TravelPlace.UserData( estimatedDuration = item.estimatedDuration.minutes, memo = item.memo, + cost = item.cost, ), startTime = parseTimeStringToDuration(item.startTime) ?: 0.hours, transportToNext = nextItem?.transportation?.firstOrNull()?.let { transport -> @@ -856,7 +927,6 @@ class TravelDetailViewModel @AssistedInject constructor( ) } - // isStartTimeSet이 false면 클라이언트에서 시간 계산 val finalPlaces = if (!isStartTimeSet && places.isNotEmpty()) { calculatePlaceStartTimes(places, Itinerary.DEFAULT_START_TIME.hours) } else { @@ -887,6 +957,65 @@ class TravelDetailViewModel @AssistedInject constructor( ), ) + private fun List.toUpdateItems(): List { + return this.flatMapIndexed { dayIndex, itinerary -> + val day = dayIndex + 1 + itinerary.places.map { place -> + ItineraryUpdateItem( + googlePlaceId = place.placeInfo.googlePlaceId, + day = day, + sequence = place.placeInfo.sequence, + startTime = if (itinerary.isStartTimeSet) place.startTime.parseDurationToTimeString() else null, + estimatedDuration = place.userData.estimatedDuration.inWholeMinutes.toInt(), + memo = place.userData.memo, + cost = place.userData.cost, + distanceKm = place.transportToNext?.distanceKm, // 지난번 만든 프로퍼티 활용 + transportation = place.transportToNext?.let { + listOf(it.toTransportationItem()) + }, + ) + } + } + } + + override suspend fun handleIntent(intent: TravelDetailIntent) { + when (intent) { + is TravelDetailIntent.SelectDay -> selectDay(intent.day) + is TravelDetailIntent.ClickStartTimeSetting -> clickStartTimeSetting() + is TravelDetailIntent.ClickEditTravel -> clickEditTravel() + is TravelDetailIntent.ClickAddScheduleButton -> clickAddScheduleButton() + is TravelDetailIntent.CheckPlaceItem -> checkPlaceItem(intent.placeId) + is TravelDetailIntent.CheckSelectAll -> checkSelectAll() + is TravelDetailIntent.ClickDeleteSelectedPlaces -> clickDeleteSelectedPlaces() + is TravelDetailIntent.ConfirmDeleteSelectedPlaces -> confirmDeleteSelectedPlaces() + is TravelDetailIntent.DismissDeleteModal -> dismissDeleteModal() + is TravelDetailIntent.ClickBack -> clickBack() + is TravelDetailIntent.ConfirmCancelEditMode -> confirmCancelEditMode() + is TravelDetailIntent.DismissCancelEditModal -> dismissCancelEditModal() + is TravelDetailIntent.LongClickPlaceItem -> longClickPlaceItem() + is TravelDetailIntent.DismissStartTimeSettingBottomSheet -> dismissStartTimeSettingBottomSheet() + is TravelDetailIntent.ConfirmStartTimeSetting -> confirmStartTimeSetting(intent.startTime) + is TravelDetailIntent.ReorderPlaces -> reorderPlaces(intent.dayIndex, intent.fromIndex, intent.toIndex) + is TravelDetailIntent.ConfirmEditMode -> confirmEditMode() + is TravelDetailIntent.ClickTransportSegment -> clickTransportSegment(intent.place) + is TravelDetailIntent.DismissTransportBottomSheet -> dismissTransportBottomSheet() + is TravelDetailIntent.ConfirmChangeTransportSegment -> confirmChangeTransportSegment(intent.segment) + is TravelDetailIntent.ClickPlaceItem -> clickPlaceItem(intent.place) + is TravelDetailIntent.DismissPlaceBottomSheet -> dismissPlaceBottomSheet() + is TravelDetailIntent.NavigateToTravelPlaceDetail -> navigateToPlaceDetail(intent.placeId) + is TravelDetailIntent.ClickAddTime -> clickAddTime() + is TravelDetailIntent.ClickAddCost -> clickAddCost() + is TravelDetailIntent.ClickAddMemo -> clickAddMemo() + is TravelDetailIntent.ClickFindRoute -> clickFindRoute(intent.googleMapsUri) + is TravelDetailIntent.DismissTimeBottomSheet -> dismissTimeBottomSheet() + is TravelDetailIntent.ConfirmDuration -> confirmDuration(intent.duration) + is TravelDetailIntent.DismissCostModal -> dismissCostModal() + is TravelDetailIntent.ConfirmCost -> confirmCost(intent.cost) + is TravelDetailIntent.DismissMemoModal -> dismissMemoModal() + is TravelDetailIntent.ConfirmMemo -> confirmMemo(intent.memo) + } + } + @AssistedFactory interface Factory { fun create(travelId: Long, days: Int): TravelDetailViewModel From c2f5d7f12a2ce79d4b472d5cfebfcbba088d94be Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 18:05:44 +0900 Subject: [PATCH 8/9] =?UTF-8?q?[NDGL-118]=20design:=20=EC=9D=BC=EC=A0=95?= =?UTF-8?q?=EC=9D=B4=20=EC=97=86=EC=9D=84=20=EB=95=8C=20=ED=99=94=EB=A9=B4?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...endar.xml => img_no_schedule_calendar.xml} | 0 .../{ic_140_serach.xml => img_search.xml} | 0 core/ui/src/main/res/values/strings.xml | 6 +++-- .../component/AddItineraryButton.kt | 2 +- .../component/SearchComponents.kt | 4 ++-- .../feature/travel/addplace/AddPlaceScreen.kt | 2 +- .../traveldetail/TravelDetailContract.kt | 3 +++ .../travel/traveldetail/TravelDetailScreen.kt | 22 ++++++++++++++----- 8 files changed, 28 insertions(+), 11 deletions(-) rename core/ui/src/main/res/drawable/{ic_140_no_schedule_calendar.xml => img_no_schedule_calendar.xml} (100%) rename core/ui/src/main/res/drawable/{ic_140_serach.xml => img_search.xml} (100%) diff --git a/core/ui/src/main/res/drawable/ic_140_no_schedule_calendar.xml b/core/ui/src/main/res/drawable/img_no_schedule_calendar.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_140_no_schedule_calendar.xml rename to core/ui/src/main/res/drawable/img_no_schedule_calendar.xml diff --git a/core/ui/src/main/res/drawable/ic_140_serach.xml b/core/ui/src/main/res/drawable/img_search.xml similarity index 100% rename from core/ui/src/main/res/drawable/ic_140_serach.xml rename to core/ui/src/main/res/drawable/img_search.xml diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 7842f95c..2207fe15 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -62,10 +62,12 @@ 여행 시작 시간 설정 편집하기 편집 완료 - 일정 추가하기 + 일정 추가하기 전체 선택 선택 삭제 - 아직 %d일차 일정이 없어요 + 일정이 없어요 + 아직 %d일차 일정이 없어요 + %d일차 첫 일정 추가하기 편집 중단 diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryButton.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryButton.kt index d1a8ae71..0d087ed5 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryButton.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/AddItineraryButton.kt @@ -30,7 +30,7 @@ internal fun AddItineraryButton( type = NDGLCTAButtonAttr.Type.PRIMARY, size = NDGLCTAButtonAttr.Size.LARGE, status = if (enabled) NDGLCTAButtonAttr.Status.ACTIVE else NDGLCTAButtonAttr.Status.DISABLED, - label = stringResource(R.string.add_schedule), + label = stringResource(R.string.add_schedule_button_text), onClick = { if (enabled) clickAddItinerary() }, ) } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt index b4fd1f9f..cde6e293 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/additinerary/component/SearchComponents.kt @@ -39,7 +39,7 @@ internal fun SearchEmptyContent() { verticalArrangement = Arrangement.spacedBy(16.dp), ) { Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_140_serach), + imageVector = ImageVector.vectorResource(R.drawable.img_search), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(140.dp), @@ -59,7 +59,7 @@ internal fun NoSearchResultContent() { horizontalAlignment = Alignment.CenterHorizontally, ) { Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_140_serach), + imageVector = ImageVector.vectorResource(R.drawable.img_search), contentDescription = null, tint = Color.Unspecified, modifier = Modifier.size(140.dp), diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/AddPlaceScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/AddPlaceScreen.kt index 7b989135..2ec35713 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/AddPlaceScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/addplace/AddPlaceScreen.kt @@ -110,7 +110,7 @@ private fun AddPlaceScreen( type = NDGLCTAButtonAttr.Type.PRIMARY, size = NDGLCTAButtonAttr.Size.LARGE, status = NDGLCTAButtonAttr.Status.ACTIVE, - label = stringResource(R.string.add_schedule), + label = stringResource(R.string.add_schedule_button_text), onClick = clickAddItinerary, ) } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt index 3f71dba8..1c3553d3 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailContract.kt @@ -44,6 +44,9 @@ data class TravelDetailState( val currentPlaces: List get() = currentItinerary?.places.orEmpty() + val isEmptyItinerary: Boolean + get() = currentItinerary?.places.isNullOrEmpty() + val representativeLatLng: LatLng get() { val currentDayPlaces = itineraries.getOrNull(selectedDay - 1)?.places diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt index 2bc69e89..113ec301 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailScreen.kt @@ -30,7 +30,6 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue -import androidx.compose.runtime.key import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -390,15 +389,21 @@ private fun TravelDetailScreen( ) { Spacer(Modifier.height(80.dp)) Icon( - imageVector = ImageVector.vectorResource(R.drawable.ic_140_no_schedule_calendar), + imageVector = ImageVector.vectorResource(R.drawable.img_empty_suitcase), contentDescription = null, tint = Color.Unspecified, ) Spacer(Modifier.height(16.dp)) Text( - text = stringResource(R.string.no_schedule_message, state.selectedDay), + text = stringResource(R.string.no_schedule_message), + color = NDGLTheme.colors.black500, + style = NDGLTheme.typography.subtitleMdSemiBold, + ) + Spacer(Modifier.height(4.dp)) + Text( + text = stringResource(R.string.no_schedule_message_detail, state.selectedDay), color = NDGLTheme.colors.black400, - style = NDGLTheme.typography.bodyLgMedium, + style = NDGLTheme.typography.bodyLgRegular, ) } } @@ -524,7 +529,14 @@ private fun TravelDetailScreen( type = NDGLCTAButtonAttr.Type.PRIMARY, size = NDGLCTAButtonAttr.Size.LARGE, status = NDGLCTAButtonAttr.Status.ACTIVE, - label = stringResource(R.string.add_schedule), + label = if (state.isEmptyItinerary) { + stringResource( + R.string.add_schedule_button_text_no_schedule, + state.selectedDay, + ) + } else { + stringResource(R.string.add_schedule_button_text) + }, onClick = clickAddScheduleButton, ) } From 1e71e681f0f55269d4d5ee6a8a54736238db8cf6 Mon Sep 17 00:00:00 2001 From: mj010504 Date: Wed, 25 Feb 2026 20:15:27 +0900 Subject: [PATCH 9/9] =?UTF-8?q?[NDGL-118]=20refactor:=20=EC=BD=94=EB=93=9C?= =?UTF-8?q?=EB=9E=98=EB=B9=97=20=EB=A6=AC=EB=B7=B0=20=EB=B0=98=EC=98=81=20?= =?UTF-8?q?-=20AddItineraryRequest=20=ED=83=80=EC=9E=85=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=20:=20String=3F=20->=20Int=3F=20-=20handleAddPlace()?= =?UTF-8?q?=20:=20lastPlace=EA=B0=80=20null=20=EC=9D=BC=EB=95=8C=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80=20-=20placeRepository:?= =?UTF-8?q?=20=EC=A3=BC=EC=84=9D=20=EC=A0=9C=EA=B1=B0=20-=20toOpeningHours?= =?UTF-8?q?:=20day=20<=3D=200=20=EC=B2=B4=ED=81=AC=20=EC=B6=94=EA=B0=80,?= =?UTF-8?q?=20java.time.*=20=EC=9D=98=EC=A1=B4=EC=84=B1=20->=20kotlinx.dat?= =?UTF-8?q?etime=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95=20-=20toTransporta?= =?UTF-8?q?tionItem()=20=EC=B5=9C=EC=86=8C=EA=B0=92=201=EB=B6=84=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../data/travel/model/AddItineraryRequest.kt | 2 +- .../data/travel/repository/PlaceRepository.kt | 1 - .../travel/repository/UserTravelRepository.kt | 2 +- .../ndgl/feature/travel/model/PlaceInfo.kt | 14 ++++--- .../feature/travel/model/TransportSegment.kt | 2 +- .../traveldetail/TravelDetailViewModel.kt | 41 +++++++++++++------ 6 files changed, 39 insertions(+), 23 deletions(-) diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt index d6df6426..56b9ca91 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/AddItineraryRequest.kt @@ -9,7 +9,7 @@ data class AddItineraryRequest( val day: Int, val sequence: Int, val startTime: String? = null, - val estimatedDuration: String? = null, + val estimatedDuration: Int, val memo: String? = null, @SerialName("budget") val cost: Int? = null, 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 12cd6a4e..71a96f47 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 @@ -34,7 +34,6 @@ class PlaceRepository @Inject constructor( val requestBuilder = FindAutocompletePredictionsRequest.builder() .setQuery(keyword) .setSessionToken(sessionToken) - // .setCountries(countryCode) .setLocationBias(bias) val response = placesClient.findAutocompletePredictions(requestBuilder.build()).await() diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt index ee92716d..b8867285 100644 --- a/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt +++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/UserTravelRepository.kt @@ -138,7 +138,7 @@ class UserTravelRepository @Inject constructor( day: Int, sequence: Int, startTime: String? = null, - estimatedDuration: String? = null, + estimatedDuration: Int, cost: Int? = null, memo: String? = null, distanceKm: Double? = null, diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt index b374ff85..3809be7b 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt @@ -2,9 +2,10 @@ package com.yapp.ndgl.feature.travel.model import com.yapp.ndgl.core.util.formatDecimal import com.yapp.ndgl.data.travel.model.PlaceDetailResponse -import java.time.DayOfWeek -import java.time.LocalDate -import java.time.format.DateTimeFormatter +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.DayOfWeek +import kotlinx.datetime.LocalDate +import kotlinx.datetime.plus import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -62,12 +63,13 @@ fun PlaceDetailResponse.toPlaceInfo(): PlaceInfo { websiteUrl = place.websiteUri, ) } + fun List?.toOpeningHours(startDate: String, day: Int): String? { - if (this.isNullOrEmpty() || startDate.isBlank()) return null + if (this.isNullOrEmpty() || startDate.isBlank() || day <= 0) return null val targetDayOfWeek = runCatching { - val travelStartDate = LocalDate.parse(startDate, DateTimeFormatter.ISO_LOCAL_DATE) - val targetDate = travelStartDate.plusDays((day - 1).toLong()) + val travelStartDate = LocalDate.parse(startDate) + val targetDate = travelStartDate.plus(day - 1, DateTimeUnit.DAY) targetDate.dayOfWeek }.getOrNull() ?: return null diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt index e55509be..87a8642e 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/TransportSegment.kt @@ -32,7 +32,7 @@ data class TransportSegment( fun toTransportationItem(): TransportationItem { return TransportationItem( mode = type.toTransportCategory(), - timeMin = duration.inWholeMinutes.toInt(), + timeMin = duration.inWholeMinutes.toInt().coerceAtLeast(1), ) } } diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt index a47a7ae1..c15adda9 100644 --- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt +++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/traveldetail/TravelDetailViewModel.kt @@ -117,18 +117,23 @@ class TravelDetailViewModel @AssistedInject constructor( val dayIndex = event.day - 1 val currentItinerary = state.value.itineraries.getOrNull(dayIndex) ?: return val newSequence = currentItinerary.places.size + 1 - val lastPlace = currentItinerary.places.last() + val lastPlace = currentItinerary.places.lastOrNull() // 추가된 장소로 가는 교통수단 계산 - val newTransportSegment = computeRoute( - originLatitude = lastPlace.placeInfo.latitude, - originLongitude = lastPlace.placeInfo.longitude, - destinationLatitude = event.latitude, - destinationLongitude = event.longitude, - newGooglePlaceId = event.googlePlaceId, - travelMode = TravelMode.TRANSIT, - ) - val (distanceKm, transportation) = if (currentItinerary.places.isNotEmpty()) { + val newTransportSegment = if (lastPlace != null) { + computeRoute( + originLatitude = lastPlace.placeInfo.latitude, + originLongitude = lastPlace.placeInfo.longitude, + destinationLatitude = event.latitude, + destinationLongitude = event.longitude, + newGooglePlaceId = event.googlePlaceId, + travelMode = TravelMode.TRANSIT, + ) + } else { + null + } + + val (distanceKm, transportation) = if (!currentItinerary.places.isNullOrEmpty()) { newTransportSegment?.distanceKm to listOfNotNull(newTransportSegment?.toTransportationItem()) } else { null to null @@ -140,8 +145,17 @@ class TravelDetailViewModel @AssistedInject constructor( googlePlaceId = event.googlePlaceId, day = event.day, sequence = newSequence, - startTime = null, - estimatedDuration = "${event.estimatedDuration}", + startTime = if (lastPlace == null) { + null + } else { + ( + lastPlace.startTime + lastPlace.placeInfo.estimatedDuration + ( + newTransportSegment?.duration + ?: 0.hours + ) + ).parseDurationToTimeString() + }, + estimatedDuration = event.estimatedDuration, cost = null, memo = null, distanceKm = distanceKm, @@ -166,7 +180,8 @@ class TravelDetailViewModel @AssistedInject constructor( userData = TravelPlace.UserData( estimatedDuration = response.estimatedDuration.minutes, ), - startTime = lastPlace.startTime + lastPlace.placeInfo.estimatedDuration + (newTransportSegment?.duration ?: 0.hours), + startTime = (lastPlace?.startTime ?: Itinerary.DEFAULT_START_TIME.hours) + + (lastPlace?.placeInfo?.estimatedDuration ?: 1.hours) + (newTransportSegment?.duration ?: 0.hours), transportToNext = null, )