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일 +