diff --git a/core/ui/src/main/res/drawable/img_empty_suitcase.xml b/core/ui/src/main/res/drawable/img_empty_suitcase.xml
new file mode 100644
index 00000000..9199bd7e
--- /dev/null
+++ b/core/ui/src/main/res/drawable/img_empty_suitcase.xml
@@ -0,0 +1,108 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml
index 77ef5227..ea5d0b97 100644
--- a/core/ui/src/main/res/values/strings.xml
+++ b/core/ui/src/main/res/values/strings.xml
@@ -12,6 +12,7 @@
비용 추가
시간 추가
메모 추가
+ •
약 %1$s • %2$s
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 15994331..68c44e1b 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,10 +1,18 @@
package com.yapp.ndgl.data.travel.api
import com.yapp.ndgl.data.core.model.BaseResponse
+import com.yapp.ndgl.data.travel.model.UpcomingTravelList
import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse
import retrofit2.http.GET
+import retrofit2.http.Query
interface UserTravelApi {
@GET("/api/v1/travels/upcoming")
suspend fun getUpcomingTravel(): BaseResponse
+
+ @GET("/api/v1/travels/upcoming/list")
+ suspend fun getUpcomingTravelList(
+ @Query("page") page: Int? = null,
+ @Query("size") size: Int? = null,
+ ): BaseResponse
}
diff --git a/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelList.kt b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelList.kt
new file mode 100644
index 00000000..248810c2
--- /dev/null
+++ b/data/travel/src/main/java/com/yapp/ndgl/data/travel/model/UpcomingTravelList.kt
@@ -0,0 +1,28 @@
+package com.yapp.ndgl.data.travel.model
+
+import com.yapp.ndgl.data.core.serializer.LocalDateSerializer
+import kotlinx.serialization.Serializable
+import java.time.LocalDate
+
+@Serializable
+data class UpcomingTravelList(
+ val content: List,
+ val hasNext: Boolean,
+) {
+ @Serializable
+ data class UpcomingTravel(
+ val id: Long,
+ val title: String,
+ val country: String,
+ val city: String,
+ @Serializable(with = LocalDateSerializer::class)
+ val startDate: LocalDate,
+ @Serializable(with = LocalDateSerializer::class)
+ val endDate: LocalDate,
+ val nights: Int,
+ val days: Int,
+ val templateId: Long,
+ val thumbnail: String? = null,
+ val profileImage: String? = 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 f71e9756..b93d111e 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,6 +3,7 @@ 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.UpcomingTravelList
import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse
import java.net.HttpURLConnection
import javax.inject.Inject
@@ -23,4 +24,8 @@ class UserTravelRepository @Inject constructor(
}
}
}
+
+ suspend fun getUpcomingTravelList(): UpcomingTravelList {
+ return userTravelApi.getUpcomingTravelList().getData()
+ }
}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelContract.kt
new file mode 100644
index 00000000..3e348e64
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelContract.kt
@@ -0,0 +1,93 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.Stable
+import com.yapp.ndgl.core.base.UiIntent
+import com.yapp.ndgl.core.base.UiSideEffect
+import com.yapp.ndgl.core.base.UiState
+import com.yapp.ndgl.data.travel.model.PlaceCategory
+import com.yapp.ndgl.data.travel.model.ProgramType
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import java.time.LocalDate
+
+@Immutable
+data class MyTravelState(
+ val upcomingTravel: UpcomingTravel? = null,
+ val upcomingTravels: ImmutableList = persistentListOf(),
+ val recommendedTravels: ImmutableList = persistentListOf(),
+) : UiState {
+ @Stable
+ sealed class UpcomingTravel {
+ abstract val travelId: Long
+ abstract val title: String
+ abstract val startDate: LocalDate
+ abstract val endDate: LocalDate
+
+ @Immutable
+ data class Upcoming(
+ override val travelId: Long,
+ override val title: String,
+ override val startDate: LocalDate,
+ override val endDate: LocalDate,
+ val imageUrl: String,
+ val dDay: Int,
+ ) : UpcomingTravel()
+
+ @Immutable
+ data class InProgress(
+ override val travelId: Long,
+ override val title: String,
+ override val startDate: LocalDate,
+ override val endDate: LocalDate,
+ val dayCount: Int,
+ val currentPlace: TravelPlace? = null,
+ ) : UpcomingTravel()
+ }
+
+ @Immutable
+ data class TravelPlace(
+ val placeId: String,
+ val category: PlaceCategory,
+ val estimatedDuration: Int,
+ val name: String,
+ val thumbnailUrl: String,
+ )
+
+ @Immutable
+ data class UpcomingTravelItem(
+ val travelId: Long,
+ val title: String,
+ val startDate: LocalDate,
+ val endDate: LocalDate,
+ val imageUrl: String,
+ val dDay: Int,
+ )
+
+ @Immutable
+ data class RecommendedTravel(
+ val travelId: Long,
+ val title: String,
+ val country: String,
+ val city: String,
+ val nights: Int,
+ val days: Int,
+ val programName: String,
+ val programType: ProgramType,
+ val thumbnailUrl: String,
+ )
+}
+
+sealed interface MyTravelIntent : UiIntent {
+ data class ClickTravel(val travelId: Long) : MyTravelIntent
+ data class ClickTravelDetail(val travelId: Long) : MyTravelIntent
+ data class ClickPlaceDetail(val placeId: String) : MyTravelIntent
+ data object ClickFindNewTravel : MyTravelIntent
+}
+
+sealed interface MyTravelSideEffect : UiSideEffect {
+ data class NavigateToFollowTravel(val travelId: Long, val days: Int) : MyTravelSideEffect
+ data class NavigateToTravelDetail(val travelId: Long) : MyTravelSideEffect
+ data class NavigateToTravelPlace(val placeId: String) : MyTravelSideEffect
+ data object NavigateToPopularTravelList : MyTravelSideEffect
+}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelScreen.kt
new file mode 100644
index 00000000..85734e90
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelScreen.kt
@@ -0,0 +1,154 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.statusBarsPadding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.material3.Scaffold
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
+import com.yapp.ndgl.core.ui.R
+import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBar
+import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationBarAttr.TextAlignType
+import com.yapp.ndgl.core.ui.designsystem.NDGLNavigationIcon
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+
+@Composable
+internal fun MyTravelRoute(
+ viewModel: MyTravelViewModel = hiltViewModel(),
+ navigateToFollowTravel: (Long, Int) -> Unit,
+ navigateToTravelDetail: (Long) -> Unit,
+ navigateToTravelPlace: (String) -> Unit,
+) {
+ val state by viewModel.collectAsState()
+
+ MyTravelScreen(
+ state = state,
+ onTravelClick = { travelId ->
+ viewModel.onIntent(MyTravelIntent.ClickTravelDetail(travelId = travelId))
+ },
+ onPlaceClick = { placeId ->
+ viewModel.onIntent(MyTravelIntent.ClickPlaceDetail(placeId = placeId))
+ },
+ onNewTravelFindClick = {
+ viewModel.onIntent(MyTravelIntent.ClickFindNewTravel)
+ },
+ onTravelTemplateClick = { travelId ->
+ viewModel.onIntent((MyTravelIntent.ClickTravel(travelId = travelId)))
+ },
+ )
+
+ viewModel.collectSideEffect { sideEffect ->
+ when (sideEffect) {
+ is MyTravelSideEffect.NavigateToFollowTravel -> navigateToFollowTravel(
+ sideEffect.travelId,
+ sideEffect.days,
+ )
+
+ is MyTravelSideEffect.NavigateToTravelDetail -> navigateToTravelDetail(
+ sideEffect.travelId,
+ )
+
+ is MyTravelSideEffect.NavigateToTravelPlace -> navigateToTravelPlace(
+ sideEffect.placeId,
+ )
+
+ MyTravelSideEffect.NavigateToPopularTravelList -> {
+ // FIXME: navigate to popular travel list
+ }
+ }
+ }
+}
+
+@Composable
+private fun MyTravelScreen(
+ state: MyTravelState,
+ onTravelClick: (Long) -> Unit,
+ onPlaceClick: (String) -> Unit,
+ onNewTravelFindClick: () -> Unit,
+ onTravelTemplateClick: (Long) -> Unit,
+) {
+ Scaffold(
+ modifier = Modifier.fillMaxSize(),
+ topBar = {
+ NDGLNavigationBar(
+ textAlignType = TextAlignType.START,
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(color = NDGLTheme.colors.white)
+ .statusBarsPadding(),
+ trailingContents = {
+ NDGLNavigationIcon(
+ icon = R.drawable.ic_28_search,
+ onClick = { /* FIXME: 홈 검색 */ },
+ )
+ NDGLNavigationIcon(
+ icon = R.drawable.ic_28_settings,
+ onClick = { /* FIXME: 설정 */ },
+ )
+ },
+ )
+ },
+ ) { innerPadding ->
+ LazyColumn(
+ modifier = Modifier
+ .padding(innerPadding)
+ .fillMaxSize(),
+ contentPadding = PaddingValues(
+ top = 20.dp,
+ bottom = 100.dp,
+ ),
+ verticalArrangement = Arrangement.spacedBy(40.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ if (state.upcomingTravel != null) {
+ item {
+ UpcomingTravelCardSection(
+ modifier = Modifier.fillMaxWidth(),
+ upcomingTravel = state.upcomingTravel,
+ onTravelClick = onTravelClick,
+ onPlaceClick = onPlaceClick,
+ )
+ }
+ }
+ item {
+ UpcomingTravelListSection(
+ upcomingTravels = state.upcomingTravels,
+ onUserTravelClick = onTravelClick,
+ onNewTravelFindClick = onNewTravelFindClick,
+ )
+ }
+ if (state.recommendedTravels.isNotEmpty()) {
+ item {
+ RecommendedTravelSection(
+ recommendedTravels = state.recommendedTravels,
+ onTravelTemplateClick = onTravelTemplateClick,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun MyTravelScreenPreview() {
+ NDGLTheme {
+ MyTravelScreen(
+ state = MyTravelState(),
+ onTravelClick = {},
+ onPlaceClick = {},
+ onNewTravelFindClick = {},
+ onTravelTemplateClick = {},
+ )
+ }
+}
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
new file mode 100644
index 00000000..121a76ed
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/MyTravelViewModel.kt
@@ -0,0 +1,155 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.lifecycle.viewModelScope
+import com.yapp.ndgl.core.base.BaseViewModel
+import com.yapp.ndgl.core.util.suspendRunCatching
+import com.yapp.ndgl.data.travel.model.UpcomingTravelResponse
+import com.yapp.ndgl.data.travel.repository.TravelTemplateRepository
+import com.yapp.ndgl.data.travel.repository.UserTravelRepository
+import dagger.hilt.android.lifecycle.HiltViewModel
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.async
+import kotlinx.coroutines.launch
+import java.time.LocalDate
+import java.time.temporal.ChronoUnit
+import javax.inject.Inject
+
+@HiltViewModel
+class MyTravelViewModel @Inject constructor(
+ private val userTravelRepository: UserTravelRepository,
+ private val travelTemplateRepository: TravelTemplateRepository,
+) : BaseViewModel(
+ initialState = MyTravelState(),
+) {
+ init {
+ viewModelScope.launch {
+ 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()
+ }
+ }
+ }
+
+ private suspend fun loadUpcomingTravel(): MyTravelState.UpcomingTravel? {
+ val travel = suspendRunCatching {
+ userTravelRepository.getUpcomingTravel()
+ }.getOrNull() ?: return null
+
+ return mapToUpcomingTravel(travel)
+ }
+
+ private fun mapToUpcomingTravel(travel: UpcomingTravelResponse): MyTravelState.UpcomingTravel? {
+ val today = LocalDate.now()
+ return when {
+ today < travel.startDate -> {
+ val dDay = ChronoUnit.DAYS.between(today, travel.startDate).toInt()
+ MyTravelState.UpcomingTravel.Upcoming(
+ travelId = travel.userTravelId,
+ title = travel.title,
+ startDate = travel.startDate,
+ endDate = travel.endDate,
+ imageUrl = travel.upcomingUserTravelPlace?.place?.thumbnail ?: "",
+ dDay = dDay,
+ )
+ }
+
+ today <= travel.endDate -> {
+ val dayCount =
+ ChronoUnit.DAYS.between(travel.startDate, today).toInt() + 1
+ val upcomingPlace = travel.upcomingUserTravelPlace
+ MyTravelState.UpcomingTravel.InProgress(
+ travelId = travel.userTravelId,
+ title = travel.title,
+ startDate = travel.startDate,
+ endDate = travel.endDate,
+ dayCount = dayCount,
+ currentPlace = upcomingPlace?.place?.let { place ->
+ MyTravelState.TravelPlace(
+ placeId = place.googlePlaceId,
+ category = place.category,
+ estimatedDuration = upcomingPlace.estimatedDuration,
+ name = place.name,
+ thumbnailUrl = place.thumbnail ?: "",
+ )
+ },
+ )
+ }
+
+ else -> null
+ }
+ }
+
+ private suspend fun loadUpcomingTravelList(): ImmutableList {
+ val result = suspendRunCatching {
+ userTravelRepository.getUpcomingTravelList()
+ }.getOrNull() ?: return persistentListOf()
+
+ val today = LocalDate.now()
+ return result.content.map { travel ->
+ val dDay = ChronoUnit.DAYS.between(today, travel.startDate).toInt()
+ MyTravelState.UpcomingTravelItem(
+ travelId = travel.id,
+ title = travel.title,
+ startDate = travel.startDate,
+ endDate = travel.endDate,
+ imageUrl = travel.thumbnail ?: "",
+ dDay = dDay,
+ )
+ }.toImmutableList()
+ }
+
+ private suspend fun loadRecommendedTravels() {
+ suspendRunCatching {
+ travelTemplateRepository.getRecommendTravelTemplates()
+ }.onSuccess { result ->
+ val travels = result.content.map { template ->
+ MyTravelState.RecommendedTravel(
+ travelId = template.id,
+ title = template.title,
+ country = template.country,
+ city = template.city,
+ nights = template.nights,
+ days = template.days,
+ programName = template.programName,
+ programType = template.programType,
+ thumbnailUrl = template.thumbnail ?: "",
+ )
+ }.toImmutableList()
+ reduce { copy(recommendedTravels = travels) }
+ }
+ }
+
+ override suspend fun handleIntent(intent: MyTravelIntent) {
+ when (intent) {
+ is MyTravelIntent.ClickTravel -> postNavigateToFollowTravel(travelId = intent.travelId)
+ is MyTravelIntent.ClickTravelDetail -> postNavigateToTravelDetail(travelId = intent.travelId)
+ is MyTravelIntent.ClickPlaceDetail -> postNavigateToPlaceDetail(placeId = intent.placeId)
+ MyTravelIntent.ClickFindNewTravel -> postNavigateToPopularTravelList()
+ }
+ }
+
+ private fun postNavigateToFollowTravel(travelId: Long, days: Int = 1) {
+ postSideEffect(MyTravelSideEffect.NavigateToFollowTravel(travelId = travelId, days = days))
+ }
+
+ private fun postNavigateToTravelDetail(travelId: Long) {
+ postSideEffect(MyTravelSideEffect.NavigateToTravelDetail(travelId = travelId))
+ }
+
+ private fun postNavigateToPlaceDetail(placeId: String) {
+ postSideEffect(MyTravelSideEffect.NavigateToTravelPlace(placeId = placeId))
+ }
+
+ private fun postNavigateToPopularTravelList() {
+ postSideEffect(MyTravelSideEffect.NavigateToPopularTravelList)
+ }
+}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/RecommendedTravelSection.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/RecommendedTravelSection.kt
new file mode 100644
index 00000000..2be77e04
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/RecommendedTravelSection.kt
@@ -0,0 +1,208 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyRow
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.core.util.FlagEmojiUtil.toFlagEmoji
+import com.yapp.ndgl.data.travel.model.ProgramType
+import com.yapp.ndgl.feature.travel.R
+import com.yapp.ndgl.feature.travel.mytravel.MyTravelState.RecommendedTravel
+import com.yapp.ndgl.feature.travel.util.toIconRes
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun RecommendedTravelSection(
+ recommendedTravels: ImmutableList,
+ onTravelTemplateClick: (Long) -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ ) {
+ Text(
+ text = stringResource(R.string.my_travel_recommended_travel_header),
+ style = NDGLTheme.typography.subtitleLgSemiBold,
+ color = NDGLTheme.colors.black900,
+ modifier = Modifier.padding(horizontal = 24.dp),
+ )
+
+ val lazyListState = rememberLazyListState()
+
+ LazyRow(
+ state = lazyListState,
+ contentPadding = PaddingValues(horizontal = 24.dp),
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
+ flingBehavior = rememberSnapFlingBehavior(lazyListState = lazyListState),
+ ) {
+ items(
+ items = recommendedTravels,
+ key = { it.travelId },
+ ) { travel ->
+ RecommendedTravelItem(
+ travel = travel,
+ onTravelTemplateClick = onTravelTemplateClick,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun RecommendedTravelItem(
+ travel: RecommendedTravel,
+ onTravelTemplateClick: (Long) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .width(240.dp)
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = { onTravelTemplateClick(travel.travelId) }),
+ ) {
+ AsyncImage(
+ model = travel.thumbnailUrl,
+ contentDescription = travel.title,
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(140.dp),
+ contentScale = ContentScale.Crop,
+ )
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .background(NDGLTheme.colors.white)
+ .padding(vertical = 16.dp),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ CountryChip(
+ country = travel.country,
+ city = travel.city,
+ )
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = travel.title,
+ color = NDGLTheme.colors.black700,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ style = NDGLTheme.typography.bodyLgSemiBold,
+ )
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(travel.programType.toIconRes()),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = NDGLTheme.colors.black400,
+ )
+ Text(
+ text = travel.programName,
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black400,
+ )
+ Text(
+ text = stringResource(CoreR.string.common_dot_separator),
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black400,
+ )
+ Text(
+ text = stringResource(
+ R.string.my_travel_recommended_travel_nights_days,
+ travel.nights,
+ travel.days,
+ ),
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black400,
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CountryChip(
+ country: String,
+ city: String,
+) {
+ Row(
+ modifier = Modifier.padding(vertical = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = country.toFlagEmoji(),
+ style = NDGLTheme.typography.bodySmSemiBold,
+ )
+ Text(
+ text = city,
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black400,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun RecommendedContentSectionPreview() {
+ NDGLTheme {
+ RecommendedTravelSection(
+ recommendedTravels = persistentListOf(
+ RecommendedTravel(
+ travelId = 1,
+ title = "곽준빈의 신혼여행",
+ country = "FR",
+ city = "파리",
+ nights = 7,
+ days = 9,
+ programName = "곽튜브",
+ programType = ProgramType.YOUTUBE,
+ thumbnailUrl = "",
+ ),
+ RecommendedTravel(
+ travelId = 2,
+ title = "스위스 여행",
+ country = "CH",
+ city = "스위스",
+ nights = 5,
+ days = 6,
+ programName = "빠니보틀",
+ programType = ProgramType.YOUTUBE,
+ thumbnailUrl = "",
+ ),
+ ),
+ onTravelTemplateClick = {},
+ )
+ }
+}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt
new file mode 100644
index 00000000..8209c1ad
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelCardSection.kt
@@ -0,0 +1,331 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.DatePickerDefaults.dateFormatter
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.core.util.formatString
+import com.yapp.ndgl.data.travel.model.PlaceCategory
+import com.yapp.ndgl.feature.travel.R
+import com.yapp.ndgl.feature.travel.mytravel.MyTravelState.TravelPlace
+import com.yapp.ndgl.feature.travel.mytravel.MyTravelState.UpcomingTravel
+import com.yapp.ndgl.feature.travel.util.toDisplayNameRes
+import com.yapp.ndgl.feature.travel.util.toDrawableRes
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import kotlin.time.Duration.Companion.minutes
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun UpcomingTravelCardSection(
+ modifier: Modifier,
+ upcomingTravel: UpcomingTravel,
+ onTravelClick: (Long) -> Unit,
+ onPlaceClick: (String) -> Unit,
+) {
+ when (upcomingTravel) {
+ is UpcomingTravel.Upcoming -> UpcomingTravelCard(
+ modifier = modifier,
+ travel = upcomingTravel,
+ onCardClick = { onTravelClick(upcomingTravel.travelId) },
+ )
+
+ is UpcomingTravel.InProgress -> InProgressTravelCard(
+ travel = upcomingTravel,
+ onTravelClick = onTravelClick,
+ onPlaceClick = onPlaceClick,
+ )
+ }
+}
+
+@Composable
+private fun UpcomingTravelCard(
+ modifier: Modifier,
+ travel: UpcomingTravel.Upcoming,
+ onCardClick: () -> Unit,
+) {
+ val dateFormat = stringResource(R.string.my_travel_upcoming_travel_date_format)
+ val dateFormatter = remember(dateFormat) {
+ DateTimeFormatter.ofPattern(dateFormat)
+ }
+
+ CardContainer(
+ modifier = modifier,
+ onCardClick = onCardClick,
+ ) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = travel.imageUrl,
+ contentDescription = travel.title,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ DayTag(dDay = travel.dDay)
+ Text(
+ text = travel.title,
+ style = NDGLTheme.typography.subtitleMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ }
+ Text(
+ text = stringResource(
+ R.string.my_travel_upcoming_travel_travel_duration,
+ travel.startDate.format(dateFormatter),
+ travel.endDate.format(dateFormatter),
+ ),
+ style = NDGLTheme.typography.bodyMdRegular,
+ color = NDGLTheme.colors.black600,
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun DayTag(
+ dDay: Int,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .background(
+ color = NDGLTheme.colors.black100,
+ shape = RoundedCornerShape(999.dp),
+ )
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = if (dDay <= 0) {
+ stringResource(R.string.my_travel_upcoming_travel_d_day_minus, dDay)
+ } else {
+ stringResource(R.string.my_travel_upcoming_travel_d_day_plus, dDay)
+ },
+ style = NDGLTheme.typography.bodyMdMedium,
+ color = NDGLTheme.colors.black400,
+ )
+ }
+}
+
+@Composable
+private fun InProgressTravelCard(
+ travel: UpcomingTravel.InProgress,
+ onTravelClick: (Long) -> Unit,
+ onPlaceClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val dateFormat = stringResource(R.string.my_travel_upcoming_travel_date_format)
+ val dateFormatter = remember(dateFormat) {
+ DateTimeFormatter.ofPattern(dateFormat)
+ }
+
+ CardContainer(
+ modifier = modifier,
+ onCardClick = { onTravelClick(travel.travelId) },
+ ) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text(
+ text = stringResource(
+ R.string.my_travel_upcoming_travel_in_progress_title,
+ travel.title,
+ travel.dayCount,
+ ),
+ style = NDGLTheme.typography.subtitleMdSemiBold,
+ color = NDGLTheme.colors.black700,
+ )
+ Text(
+ text = stringResource(
+ R.string.my_travel_upcoming_travel_travel_duration,
+ travel.startDate.format(dateFormatter),
+ travel.endDate.format(dateFormatter),
+ ),
+ style = NDGLTheme.typography.bodyMdRegular,
+ color = NDGLTheme.colors.black500,
+ )
+ }
+
+ if (travel.currentPlace != null) {
+ PlaceInfoCard(
+ place = travel.currentPlace,
+ onPlaceClick = { onPlaceClick(travel.currentPlace.placeId) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PlaceInfoCard(
+ place: TravelPlace,
+ onPlaceClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ modifier = modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp))
+ .background(NDGLTheme.colors.white)
+ .clickable(onClick = onPlaceClick)
+ .padding(16.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(10.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Icon(
+ imageVector = ImageVector.vectorResource(place.category.toDrawableRes()),
+ contentDescription = null,
+ modifier = Modifier.size(14.dp),
+ tint = NDGLTheme.colors.black400,
+ )
+ Text(
+ text = stringResource(place.category.toDisplayNameRes()),
+ color = NDGLTheme.colors.black400,
+ style = NDGLTheme.typography.bodySmMedium,
+ )
+ Text(
+ text = stringResource(CoreR.string.common_dot_separator),
+ color = NDGLTheme.colors.black400,
+ style = NDGLTheme.typography.bodyMdMedium,
+ )
+ Text(
+ text = stringResource(
+ CoreR.string.estimated_duration_format,
+ place.estimatedDuration.minutes.formatString(),
+ ),
+ color = NDGLTheme.colors.black400,
+ style = NDGLTheme.typography.bodySmMedium,
+ )
+ }
+ Text(
+ text = place.name,
+ color = NDGLTheme.colors.black900,
+ style = NDGLTheme.typography.bodyLgSemiBold,
+ )
+ }
+
+ AsyncImage(
+ model = place.thumbnailUrl,
+ contentDescription = place.name,
+ modifier = Modifier
+ .size(56.dp)
+ .clip(RoundedCornerShape(4.dp)),
+ contentScale = ContentScale.Crop,
+ )
+ }
+}
+
+@Composable
+private fun CardContainer(
+ modifier: Modifier,
+ onCardClick: () -> Unit,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ Box(
+ modifier = modifier
+ .padding(horizontal = 24.dp)
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(4.dp))
+ .background(NDGLTheme.colors.black50)
+ .clickable(onClick = onCardClick),
+ content = content,
+ )
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun UpcomingTravelCardPreview() {
+ NDGLTheme {
+ UpcomingTravelCardSection(
+ modifier = Modifier,
+ upcomingTravel = UpcomingTravel.Upcoming(
+ travelId = 1L,
+ title = "도쿄 여행",
+ startDate = LocalDate.of(2025, 2, 15),
+ endDate = LocalDate.of(2025, 2, 20),
+ dDay = -7,
+ imageUrl = "",
+ ),
+ onTravelClick = {},
+ onPlaceClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun InProgressTravelCardPreview() {
+ NDGLTheme {
+ UpcomingTravelCardSection(
+ modifier = Modifier,
+ upcomingTravel = UpcomingTravel.InProgress(
+ travelId = 1L,
+ title = "인도 여행",
+ startDate = LocalDate.of(2025, 2, 1),
+ endDate = LocalDate.of(2025, 2, 10),
+ dayCount = 3,
+ currentPlace = TravelPlace(
+ placeId = "place1",
+ category = PlaceCategory.ATTRACTION,
+ estimatedDuration = 60,
+ name = "인도 국제 공항",
+ thumbnailUrl = "",
+ ),
+ ),
+ onTravelClick = {},
+ onPlaceClick = {},
+ )
+ }
+}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt
new file mode 100644
index 00000000..e69e0216
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/mytravel/UpcomingTravelListSection.kt
@@ -0,0 +1,307 @@
+package com.yapp.ndgl.feature.travel.mytravel
+
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.wrapContentSize
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material3.Icon
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.vector.ImageVector
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.res.vectorResource
+import androidx.compose.ui.tooling.preview.Preview
+import androidx.compose.ui.unit.dp
+import coil3.compose.AsyncImage
+import com.yapp.ndgl.core.ui.theme.NDGLTheme
+import com.yapp.ndgl.feature.travel.R
+import com.yapp.ndgl.feature.travel.mytravel.MyTravelState.UpcomingTravelItem
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+import java.time.LocalDate
+import java.time.format.DateTimeFormatter
+import com.yapp.ndgl.core.ui.R as CoreR
+
+@Composable
+internal fun UpcomingTravelListSection(
+ upcomingTravels: ImmutableList,
+ onUserTravelClick: (Long) -> Unit,
+ onNewTravelFindClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier.fillMaxWidth(),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Header()
+ if (upcomingTravels.isNotEmpty()) {
+ upcomingTravels.forEach { travel ->
+ UpcomingTravel(
+ travel = travel,
+ onUserTravelClick = onUserTravelClick,
+ )
+ }
+ } else {
+ EmptyTravel(
+ onNewTravelFindClick = onNewTravelFindClick,
+ )
+ }
+ }
+}
+
+@Composable
+private fun Header() {
+ Text(
+ text = stringResource(R.string.my_travel_upcoming_list_header),
+ modifier = Modifier.padding(horizontal = 24.dp),
+ color = NDGLTheme.colors.black700,
+ style = NDGLTheme.typography.subtitleLgSemiBold,
+ )
+}
+
+@Composable
+private fun UpcomingTravel(
+ travel: UpcomingTravelItem,
+ onUserTravelClick: (Long) -> Unit,
+) {
+ val dateFormat = stringResource(R.string.my_travel_upcoming_list_date_format)
+ val dateFormatter = remember(dateFormat) {
+ DateTimeFormatter.ofPattern(dateFormat)
+ }
+
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = { onUserTravelClick(travel.travelId) })
+ .padding(horizontal = 24.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ AsyncImage(
+ model = travel.imageUrl,
+ contentDescription = travel.title,
+ modifier = Modifier
+ .size(64.dp)
+ .clip(CircleShape),
+ contentScale = ContentScale.Crop,
+ )
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(6.dp),
+ ) {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ DayTag(dDay = travel.dDay)
+ Text(
+ text = travel.title,
+ color = NDGLTheme.colors.black700,
+ style = NDGLTheme.typography.subtitleMdSemiBold,
+ )
+ }
+ Text(
+ text = stringResource(
+ R.string.my_travel_upcoming_list_travel_duration,
+ travel.startDate.format(dateFormatter),
+ travel.endDate.format(dateFormatter),
+ ),
+ color = NDGLTheme.colors.black600,
+ style = NDGLTheme.typography.bodyMdRegular,
+ )
+ }
+ }
+}
+
+@Composable
+private fun DayTag(
+ dDay: Int,
+ modifier: Modifier = Modifier,
+) {
+ Box(
+ modifier = modifier
+ .background(
+ color = NDGLTheme.colors.black100,
+ shape = RoundedCornerShape(999.dp),
+ )
+ .padding(horizontal = 12.dp, vertical = 4.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = if (dDay > 0) {
+ stringResource(R.string.my_travel_upcoming_list_d_day_minus, dDay)
+ } else {
+ stringResource(R.string.my_travel_upcoming_list_d_day_plus, dDay)
+ },
+ color = NDGLTheme.colors.black400,
+ style = NDGLTheme.typography.bodyMdMedium,
+ )
+ }
+}
+
+@Composable
+private fun EmptyTravel(
+ onNewTravelFindClick: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 24.dp),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Image(
+ painter = painterResource(CoreR.drawable.img_empty_suitcase),
+ contentDescription = null,
+ modifier = Modifier.size(100.dp),
+ )
+ Text(
+ text = stringResource(R.string.my_travel_upcoming_list_empty_title),
+ modifier = Modifier.padding(top = 16.dp),
+ color = NDGLTheme.colors.black500,
+ style = NDGLTheme.typography.subtitleMdSemiBold,
+ )
+ Text(
+ text = stringResource(R.string.my_travel_upcoming_list_empty_description),
+ modifier = Modifier.padding(top = 4.dp),
+ color = NDGLTheme.colors.black400,
+ style = NDGLTheme.typography.bodyLgRegular,
+ )
+ FindNewTravelCtaButton(
+ modifier = Modifier.padding(top = 12.dp),
+ onClick = onNewTravelFindClick,
+ )
+ }
+}
+
+@Composable
+private fun FindNewTravelCtaButton(
+ modifier: Modifier,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = modifier
+ .wrapContentSize()
+ .clip(RoundedCornerShape(8.dp))
+ .clickable(onClick = onClick)
+ .background(color = NDGLTheme.colors.black200)
+ .padding(horizontal = 16.dp, vertical = 8.dp),
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = stringResource(R.string.my_travel_upcoming_list_find_new_travel),
+ color = NDGLTheme.colors.black800,
+ style = NDGLTheme.typography.bodyMdSemiBold,
+ )
+ Icon(
+ imageVector = ImageVector.vectorResource(CoreR.drawable.ic_20_search),
+ contentDescription = null,
+ modifier = Modifier.size(20.dp),
+ tint = NDGLTheme.colors.black600,
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun UpcomingTravelListSectionPreview() {
+ NDGLTheme {
+ UpcomingTravelListSection(
+ upcomingTravels = persistentListOf(
+ UpcomingTravelItem(
+ travelId = 1L,
+ title = "도쿄 여행",
+ startDate = LocalDate.of(2026, 3, 1),
+ endDate = LocalDate.of(2026, 3, 4),
+ imageUrl = "",
+ dDay = -7,
+ ),
+ UpcomingTravelItem(
+ travelId = 2L,
+ title = "오사카 여행",
+ startDate = LocalDate.of(2026, 4, 10),
+ endDate = LocalDate.of(2026, 4, 14),
+ imageUrl = "",
+ dDay = -47,
+ ),
+ ),
+ onUserTravelClick = {},
+ onNewTravelFindClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun UpcomingTravelListSectionEmptyPreview() {
+ NDGLTheme {
+ UpcomingTravelListSection(
+ upcomingTravels = persistentListOf(),
+ onUserTravelClick = {},
+ onNewTravelFindClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun UpcomingTravelPreview() {
+ NDGLTheme {
+ UpcomingTravel(
+ travel = UpcomingTravelItem(
+ travelId = 1L,
+ title = "도쿄 여행",
+ startDate = LocalDate.of(2026, 3, 1),
+ endDate = LocalDate.of(2026, 3, 4),
+ imageUrl = "",
+ dDay = -7,
+ ),
+ onUserTravelClick = {},
+ )
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun DayTagPreview() {
+ NDGLTheme {
+ Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
+ DayTag(dDay = -7)
+ DayTag(dDay = 0)
+ DayTag(dDay = 3)
+ }
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun EmptyTravelPreview() {
+ NDGLTheme {
+ EmptyTravel(onNewTravelFindClick = {})
+ }
+}
+
+@Preview(showBackground = true)
+@Composable
+private fun FindNewTravelCtaButtonPreview() {
+ NDGLTheme {
+ FindNewTravelCtaButton(
+ modifier = Modifier,
+ onClick = {},
+ )
+ }
+}
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 9a638765..0df0e585 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
@@ -9,9 +9,9 @@ import com.yapp.ndgl.feature.travel.followtravel.FollowTravelRoute
import com.yapp.ndgl.feature.travel.followtravel.FollowTravelViewModel
import com.yapp.ndgl.feature.travel.followtravel.placedetail.FollowPlaceDetailRoute
import com.yapp.ndgl.feature.travel.followtravel.placedetail.FollowPlaceDetailViewModel
+import com.yapp.ndgl.feature.travel.mytravel.MyTravelRoute
import com.yapp.ndgl.feature.travel.placedetail.PlaceDetailRoute
import com.yapp.ndgl.feature.travel.placedetail.PlaceDetailViewModel
-import com.yapp.ndgl.feature.travel.travel.TravelRoute
import com.yapp.ndgl.feature.travel.traveldetail.TravelDetailRoute
import com.yapp.ndgl.feature.travel.traveldetail.TravelDetailViewModel
import com.yapp.ndgl.navigation.Navigator
@@ -21,12 +21,16 @@ import com.yapp.ndgl.navigation.model.RouteTipContent
fun EntryProviderScope.travelEntry(navigator: Navigator) {
entry {
- TravelRoute(
+ MyTravelRoute(
navigateToFollowTravel = { travelId, days ->
navigator.navigate(Route.FollowTravel(travelId, days))
},
navigateToTravelDetail = { travelId ->
- navigator.navigate(Route.TravelDetail(travelId))
+ // FIXME: Travel id long 타입으로 변경
+ navigator.navigate(Route.TravelDetail(travelId.toInt()))
+ },
+ navigateToTravelPlace = { placeId ->
+ navigator.navigate(Route.PlaceDetail(placeId))
},
)
}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelContract.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelContract.kt
deleted file mode 100644
index dc63914f..00000000
--- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelContract.kt
+++ /dev/null
@@ -1,19 +0,0 @@
-package com.yapp.ndgl.feature.travel.travel
-
-import com.yapp.ndgl.core.base.UiIntent
-import com.yapp.ndgl.core.base.UiSideEffect
-import com.yapp.ndgl.core.base.UiState
-
-data class TravelState(
- val displayText: String = "초기 상태",
-) : UiState
-
-sealed interface TravelIntent : UiIntent {
- data class ClickTravel(val travelId: Int) : TravelIntent
- data class ClickTravelDetail(val travelId: Int) : TravelIntent
-}
-
-sealed interface TravelSideEffect : UiSideEffect {
- data class NavigateToFollowTravel(val travelId: Long, val days: Int) : TravelSideEffect
- data class NavigateToTravelDetail(val travelId: Int) : TravelSideEffect
-}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelScreen.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelScreen.kt
deleted file mode 100644
index 0770b2ed..00000000
--- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelScreen.kt
+++ /dev/null
@@ -1,83 +0,0 @@
-package com.yapp.ndgl.feature.travel.travel
-
-import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.PaddingValues
-import androidx.compose.foundation.layout.fillMaxSize
-import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.lazy.LazyColumn
-import androidx.compose.material3.Button
-import androidx.compose.material3.Text
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.getValue
-import androidx.compose.ui.Alignment
-import androidx.compose.ui.Modifier
-import androidx.compose.ui.tooling.preview.Preview
-import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel
-
-@Composable
-internal fun TravelRoute(
- navigateToFollowTravel: (Long, Int) -> Unit,
- navigateToTravelDetail: (Int) -> Unit,
- innerPadding: PaddingValues = PaddingValues(),
- viewModel: TravelViewModel = hiltViewModel(),
-) {
- val state by viewModel.collectAsState()
-
- TravelScreen(
- state = state,
- clickTravel = { id -> viewModel.onIntent(TravelIntent.ClickTravel(id)) },
- clickTravelDetail = { id -> viewModel.onIntent(TravelIntent.ClickTravelDetail(id)) },
- innerPadding = innerPadding,
- )
-
- viewModel.collectSideEffect { sideEffect ->
- when (sideEffect) {
- is TravelSideEffect.NavigateToFollowTravel -> navigateToFollowTravel(sideEffect.travelId, sideEffect.days)
- is TravelSideEffect.NavigateToTravelDetail -> navigateToTravelDetail(sideEffect.travelId)
- }
- }
-}
-
-@Composable
-private fun TravelScreen(
- state: TravelState = TravelState(),
- clickTravel: (Int) -> Unit = {},
- clickTravelDetail: (Int) -> Unit = {},
- innerPadding: PaddingValues,
-) {
- LazyColumn(
- modifier = Modifier
- .fillMaxSize()
- .padding(innerPadding),
- verticalArrangement = Arrangement.Center,
- horizontalAlignment = Alignment.CenterHorizontally,
- ) {
- item {
- Text(text = "Travel Screen")
- }
- item {
- Button(
- onClick = {
- clickTravel(123)
- },
- ) {
- Text(text = "Go to Follow Travel")
- }
- }
- item {
- Button(
- onClick = {
- clickTravelDetail(456)
- },
- ) {
- Text(text = "Go to Travel Detail")
- }
- }
- }
-}
-
-@Preview(showBackground = true)
-@Composable
-private fun TravelScreenPreview() {
- TravelScreen(innerPadding = PaddingValues())
-}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelViewModel.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelViewModel.kt
deleted file mode 100644
index d3975e29..00000000
--- a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/travel/TravelViewModel.kt
+++ /dev/null
@@ -1,33 +0,0 @@
-package com.yapp.ndgl.feature.travel.travel
-
-import com.yapp.ndgl.core.base.BaseViewModel
-import dagger.hilt.android.lifecycle.HiltViewModel
-import javax.inject.Inject
-
-@HiltViewModel
-class TravelViewModel @Inject constructor() : BaseViewModel(
- initialState = TravelState(),
-) {
- override suspend fun handleIntent(intent: TravelIntent) {
- when (intent) {
- is TravelIntent.ClickTravel -> {
- clickTravel(intent.travelId)
- }
- is TravelIntent.ClickTravelDetail -> {
- clickTravelDetail(intent.travelId)
- }
- }
- }
-
- // FIXME: Home 화면에서 처리
- private fun clickTravel(travelId: Int) {
- reduce { copy(displayText = "클릭된 id: $travelId") }
- postSideEffect(TravelSideEffect.NavigateToFollowTravel(2, 1))
- }
-
- // FIXME: Home 화면에서 처리
- private fun clickTravelDetail(travelId: Int) {
- reduce { copy(displayText = "Travel Detail 클릭된 id: $travelId") }
- postSideEffect(TravelSideEffect.NavigateToTravelDetail(travelId))
- }
-}
diff --git a/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/util/ResourceUtil.kt b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/util/ResourceUtil.kt
new file mode 100644
index 00000000..00932981
--- /dev/null
+++ b/feature/travel/src/main/java/com/yapp/ndgl/feature/travel/util/ResourceUtil.kt
@@ -0,0 +1,28 @@
+package com.yapp.ndgl.feature.travel.util
+
+import com.yapp.ndgl.data.travel.model.PlaceCategory
+import com.yapp.ndgl.data.travel.model.ProgramType
+import com.yapp.ndgl.core.ui.R as CoreR
+
+fun PlaceCategory.toDisplayNameRes() = when (this) {
+ PlaceCategory.AIRPORT -> CoreR.string.place_type_airport
+ PlaceCategory.TRANSPORT -> CoreR.string.place_type_transport
+ PlaceCategory.ATTRACTION -> CoreR.string.place_type_attraction
+ PlaceCategory.RESTAURANT -> CoreR.string.place_type_restaurant
+ PlaceCategory.CAFE -> CoreR.string.place_type_cafe
+ PlaceCategory.ACCOMMODATION -> CoreR.string.place_type_accommodation
+}
+
+fun PlaceCategory.toDrawableRes() = when (this) {
+ PlaceCategory.AIRPORT -> CoreR.drawable.ic_14_airplane
+ PlaceCategory.TRANSPORT -> CoreR.drawable.ic_14_car
+ PlaceCategory.ATTRACTION -> CoreR.drawable.ic_14_flag
+ PlaceCategory.RESTAURANT -> CoreR.drawable.ic_14_restaurant
+ PlaceCategory.CAFE -> CoreR.drawable.ic_14_coffee
+ PlaceCategory.ACCOMMODATION -> CoreR.drawable.ic_14_home
+}
+
+fun ProgramType.toIconRes() = when (this) {
+ ProgramType.YOUTUBE -> CoreR.drawable.ic_20_video
+ ProgramType.TV -> CoreR.drawable.ic_20_tv
+}
diff --git a/feature/travel/src/main/res/values/strings.xml b/feature/travel/src/main/res/values/strings.xml
new file mode 100644
index 00000000..f91bd37e
--- /dev/null
+++ b/feature/travel/src/main/res/values/strings.xml
@@ -0,0 +1,25 @@
+
+
+
+ 아직 등록된 여행지가 없어요
+ 새 여행 일정을 만들어 보세요!
+ %s~%s
+ M월 d일
+ %1$s %2$d일차 입니다!
+ D-%d
+ D+%d
+
+
+ 다가오는 여행
+ M월 d일
+ %s~%s
+ D-%d
+ D+%d
+ 아직 예정된 여행이 없어요.
+ 따라가기 영상을 담아두면 여행 준비가 쉬워져요.
+ 새로운 여행지 찾아보기
+
+
+ 추천하는 따라가기 여행
+ %1$d박 %2$d일
+