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(),
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/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..56b9ca91
--- /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: Int,
+ 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/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/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/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/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/PlaceRepository.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/repository/PlaceRepository.kt
index 1c903add..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
@@ -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,16 @@ 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)
+ .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 c4197831..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
@@ -3,13 +3,18 @@ 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.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
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 +36,32 @@ 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()
+
+ 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)
}
+ suspend fun emitTravelCreatedEvent(event: TravelCreatedEvent) {
+ _travelCreatedEvent.emit(event)
+ }
+
+ suspend fun emitChangePlaceEvent(event: ChangePlaceEvent) {
+ _changePlaceEvent.emit(event)
+ }
+
suspend fun getUpcomingTravel(): UpcomingTravelResponse? {
return try {
userTravelApi.getUpcomingTravel().getData()
@@ -51,6 +78,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 +116,45 @@ 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(
- id = travelId,
- ).getData()
- }
+ googlePlaceId: String,
+ day: Int,
+ sequence: Int,
+ startTime: String? = null,
+ estimatedDuration: Int,
+ cost: Int? = null,
+ memo: String? = null,
+ 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/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/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/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/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 ->
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/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/model/PlaceInfo.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/model/PlaceInfo.kt
index 5a4306a6..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,6 +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 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
@@ -59,3 +63,26 @@ fun PlaceDetailResponse.toPlaceInfo(): PlaceInfo {
websiteUrl = place.websiteUri,
)
}
+
+fun List?.toOpeningHours(startDate: String, day: Int): String? {
+ if (this.isNullOrEmpty() || startDate.isBlank() || day <= 0) return null
+
+ val targetDayOfWeek = runCatching {
+ val travelStartDate = LocalDate.parse(startDate)
+ val targetDate = travelStartDate.plus(day - 1, DateTimeUnit.DAY)
+ 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()
+}
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..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
@@ -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().coerceAtLeast(1),
+ )
+ }
}
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()
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/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
}
}
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..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
@@ -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(),
@@ -42,10 +44,13 @@ 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
- val firstPlaceInSelectedDay = currentDayPlaces?.firstOrNull()
+ val firstPlaceInSelectedDay = currentDayPlaces?.lastOrNull()
if (firstPlaceInSelectedDay != null) {
return LatLng(firstPlaceInSelectedDay.placeInfo.latitude, firstPlaceInSelectedDay.placeInfo.longitude)
@@ -54,7 +59,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 +143,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 +157,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..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
@@ -99,7 +98,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 +111,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 +150,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)
+ }
+ }
+ }
}
}
@@ -370,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,
)
}
}
@@ -431,16 +456,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))
}
}
}
@@ -502,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,
)
}
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..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
@@ -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,172 @@ 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 lastPlace = currentItinerary.places.lastOrNull()
+
+ // 추가된 장소로 가는 교통수단 계산
+ 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 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 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.isNullOrEmpty()) {
+ 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 = if (lastPlace == null) {
+ null
+ } else {
+ (
+ lastPlace.startTime + lastPlace.placeInfo.estimatedDuration + (
+ newTransportSegment?.duration
+ ?: 0.hours
+ )
+ ).parseDurationToTimeString()
+ },
+ 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 ?: Itinerary.DEFAULT_START_TIME.hours) +
+ (lastPlace?.placeInfo?.estimatedDuration ?: 1.hours) + (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 +481,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 +553,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 +601,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 +640,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 +666,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 +714,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 +774,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 +809,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 +928,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 +942,6 @@ class TravelDetailViewModel @AssistedInject constructor(
)
}
- // isStartTimeSet이 false면 클라이언트에서 시간 계산
val finalPlaces = if (!isStartTimeSet && places.isNotEmpty()) {
calculatePlaceStartTimes(places, Itinerary.DEFAULT_START_TIME.hours)
} else {
@@ -887,6 +972,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
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