diff --git a/app/src/main/java/com/moive/app/data/condition/mapper/ConditionMapper.kt b/app/src/main/java/com/moive/app/data/condition/mapper/ConditionMapper.kt index 83ea3cb5..f68fe129 100644 --- a/app/src/main/java/com/moive/app/data/condition/mapper/ConditionMapper.kt +++ b/app/src/main/java/com/moive/app/data/condition/mapper/ConditionMapper.kt @@ -19,6 +19,13 @@ fun ConditionResponse.toModel(): ConditionModel = fun AvailableScheduleModel.toRequest(): AvailableScheduleRequest = AvailableScheduleRequest(date = date, time = time) +enum class TravelTime(val label: String, val maxMinutes: Int?) { + WITHIN_30("30분 이내", 30), + WITHIN_60("1시간 이내", 60), + WITHIN_90("1시간 30분 이내", 90), + NO_PREFERENCE("상관 없어요", null), +} + @Serializable enum class ActivityType(val label: String) { BOARD_GAME("보드게임"), diff --git a/app/src/main/java/com/moive/app/data/home/mapper/HomeMapper.kt b/app/src/main/java/com/moive/app/data/home/mapper/HomeMapper.kt index 4daa5cb8..a04a9862 100644 --- a/app/src/main/java/com/moive/app/data/home/mapper/HomeMapper.kt +++ b/app/src/main/java/com/moive/app/data/home/mapper/HomeMapper.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.toPersistentList fun HomeResponse.toModel(): HomeModel = HomeModel( + nickname = nickname, confirmedMeetings = confirmedMeetings.map { it.toModel() }.toImmutableList(), myMeetings = myMeetings.map { it.toModel() }.toImmutableList(), ) diff --git a/app/src/main/java/com/moive/app/data/home/model/HomeModel.kt b/app/src/main/java/com/moive/app/data/home/model/HomeModel.kt index 374309bf..2917a4d8 100644 --- a/app/src/main/java/com/moive/app/data/home/model/HomeModel.kt +++ b/app/src/main/java/com/moive/app/data/home/model/HomeModel.kt @@ -5,6 +5,7 @@ import kotlinx.collections.immutable.ImmutableList @Immutable data class HomeModel( + val nickname: String, val confirmedMeetings: ImmutableList, val myMeetings: ImmutableList, ) diff --git a/app/src/main/java/com/moive/app/data/home/remote/dto/HomeResponse.kt b/app/src/main/java/com/moive/app/data/home/remote/dto/HomeResponse.kt index 968a89ab..d682b10a 100644 --- a/app/src/main/java/com/moive/app/data/home/remote/dto/HomeResponse.kt +++ b/app/src/main/java/com/moive/app/data/home/remote/dto/HomeResponse.kt @@ -5,6 +5,8 @@ import kotlinx.serialization.Serializable @Serializable data class HomeResponse( + @SerialName("nickname") + val nickname: String, @SerialName("confirmedMeetings") val confirmedMeetings: List, @SerialName("myMeetings") diff --git a/app/src/main/java/com/moive/app/data/voting/mapper/VotingMapper.kt b/app/src/main/java/com/moive/app/data/voting/mapper/VotingMapper.kt index 8a9cd39d..860e0e9c 100644 --- a/app/src/main/java/com/moive/app/data/voting/mapper/VotingMapper.kt +++ b/app/src/main/java/com/moive/app/data/voting/mapper/VotingMapper.kt @@ -37,6 +37,7 @@ fun RecommendedPlaceDetailResponse.applyTo(place: PlaceDetailModel): PlaceDetail category = category, address = address, areaName = areaName, + totalMemberCount = participantCnt, matchMemberCount = preferenceMatchCnt, avgTravelMinutes = averageTravelTime, imageList = imageUrls, diff --git a/app/src/main/java/com/moive/app/data/voting/remote/dto/RecommendedPlaceDetailResponse.kt b/app/src/main/java/com/moive/app/data/voting/remote/dto/RecommendedPlaceDetailResponse.kt index 954b3137..1343ba45 100644 --- a/app/src/main/java/com/moive/app/data/voting/remote/dto/RecommendedPlaceDetailResponse.kt +++ b/app/src/main/java/com/moive/app/data/voting/remote/dto/RecommendedPlaceDetailResponse.kt @@ -15,6 +15,8 @@ data class RecommendedPlaceDetailResponse( val address: String, @SerialName("areaName") val areaName: String, + @SerialName("participantCnt") + val participantCnt: Int, @SerialName("preferenceMatchCnt") val preferenceMatchCnt: Int, @SerialName("averageTravelTime") diff --git a/app/src/main/java/com/moive/app/presentation/condition/ConditionCalendarUtils.kt b/app/src/main/java/com/moive/app/presentation/condition/ConditionCalendarUtils.kt index 6c61159b..093a8813 100644 --- a/app/src/main/java/com/moive/app/presentation/condition/ConditionCalendarUtils.kt +++ b/app/src/main/java/com/moive/app/presentation/condition/ConditionCalendarUtils.kt @@ -1,14 +1,19 @@ package com.moive.app.presentation.condition +import androidx.compose.runtime.Immutable import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toPersistentList import java.util.Calendar +@Immutable data class CalendarDay( val day: Int, val isCurrentMonth: Boolean, + val isSelectable: Boolean, ) +private const val SELECTABLE_RANGE_DAYS = 42 + fun buildCalendarDays(year: Int, month: Int): ImmutableList { val calendar = Calendar.getInstance() calendar.clear() @@ -21,6 +26,16 @@ fun buildCalendarDays(year: Int, month: Int): ImmutableList { previousMonthCalendar.add(Calendar.MONTH, -1) val daysInPreviousMonth = previousMonthCalendar.getActualMaximum(Calendar.DAY_OF_MONTH) + val today = Calendar.getInstance().apply { + set(Calendar.HOUR_OF_DAY, 0) + set(Calendar.MINUTE, 0) + set(Calendar.SECOND, 0) + set(Calendar.MILLISECOND, 0) + } + val maxSelectableDate = (today.clone() as Calendar).apply { + add(Calendar.DAY_OF_MONTH, SELECTABLE_RANGE_DAYS - 1) + } + val leadingCount = firstDayOfWeek - 1 val days = mutableListOf() @@ -29,22 +44,28 @@ fun buildCalendarDays(year: Int, month: Int): ImmutableList { CalendarDay( day = daysInPreviousMonth - leadingCount + 1 + i, isCurrentMonth = false, + isSelectable = false, ), ) } for (day in 1..daysInMonth) { - days.add(CalendarDay(day = day, isCurrentMonth = true)) + val date = (calendar.clone() as Calendar).apply { set(Calendar.DAY_OF_MONTH, day) } + val isSelectable = !date.before(today) && !date.after(maxSelectableDate) + days.add(CalendarDay(day = day, isCurrentMonth = true, isSelectable = isSelectable)) } val trailingCount = (7 - days.size % 7) % 7 for (day in 1..trailingCount) { - days.add(CalendarDay(day = day, isCurrentMonth = false)) + days.add(CalendarDay(day = day, isCurrentMonth = false, isSelectable = false)) } return days.toPersistentList() } +fun monthHasSelectableDay(year: Int, month: Int): Boolean = + buildCalendarDays(year, month).any { it.isCurrentMonth && it.isSelectable } + fun previousMonth(year: Int, month: Int): Pair { val calendar = Calendar.getInstance() calendar.clear() diff --git a/app/src/main/java/com/moive/app/presentation/condition/ConditionContract.kt b/app/src/main/java/com/moive/app/presentation/condition/ConditionContract.kt index f4fed2f7..886b2926 100644 --- a/app/src/main/java/com/moive/app/presentation/condition/ConditionContract.kt +++ b/app/src/main/java/com/moive/app/presentation/condition/ConditionContract.kt @@ -4,6 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.Immutable import com.moive.app.core.designsystem.component.toast.ToastType import com.moive.app.data.condition.mapper.ActivityType +import com.moive.app.data.condition.mapper.TravelTime import com.moive.app.data.condition.model.PlaceSearchItemModel import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.PersistentList @@ -40,6 +41,12 @@ interface ConditionContract { val calendarDays: ImmutableList get() = buildCalendarDays(calendarYear, calendarMonth) + val hasPreviousMonth: Boolean + get() = previousMonth(calendarYear, calendarMonth).let { (year, month) -> monthHasSelectableDay(year, month) } + + val hasNextMonth: Boolean + get() = nextMonth(calendarYear, calendarMonth).let { (year, month) -> monthHasSelectableDay(year, month) } + val timeOptions: ImmutableList get() = TIME_OPTIONS @@ -144,13 +151,6 @@ sealed interface ConditionUiState { ) : ConditionUiState } -enum class TravelTime(val label: String, val maxMinutes: Int?) { - WITHIN_30("30분 이내", 30), - WITHIN_60("1시간 이내", 60), - WITHIN_90("1시간 30분 이내", 90), - NO_PREFERENCE("상관 없어요", null), -} - fun String.toMaxTravelMinutes(): Int? = TravelTime.entries.find { it.label == this }?.maxMinutes diff --git a/app/src/main/java/com/moive/app/presentation/condition/ConditionViewModel.kt b/app/src/main/java/com/moive/app/presentation/condition/ConditionViewModel.kt index 3dbf71fc..3aae3bf4 100644 --- a/app/src/main/java/com/moive/app/presentation/condition/ConditionViewModel.kt +++ b/app/src/main/java/com/moive/app/presentation/condition/ConditionViewModel.kt @@ -92,6 +92,7 @@ class ConditionViewModel @Inject constructor( fun onCalendarPrevMonthClick() { _uiState.update { val (year, month) = previousMonth(it.calendarYear, it.calendarMonth) + if (!monthHasSelectableDay(year, month)) return@update it it.copy(calendarYear = year, calendarMonth = month, pendingDay = null) } } @@ -99,6 +100,7 @@ class ConditionViewModel @Inject constructor( fun onCalendarNextMonthClick() { _uiState.update { val (year, month) = nextMonth(it.calendarYear, it.calendarMonth) + if (!monthHasSelectableDay(year, month)) return@update it it.copy(calendarYear = year, calendarMonth = month, pendingDay = null) } } diff --git a/app/src/main/java/com/moive/app/presentation/condition/component/DateBottomSheet.kt b/app/src/main/java/com/moive/app/presentation/condition/component/DateBottomSheet.kt index 910405db..8ca85f46 100644 --- a/app/src/main/java/com/moive/app/presentation/condition/component/DateBottomSheet.kt +++ b/app/src/main/java/com/moive/app/presentation/condition/component/DateBottomSheet.kt @@ -14,7 +14,6 @@ import androidx.compose.foundation.layout.aspectRatio 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.wrapContentSize import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.items @@ -22,7 +21,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.SheetState -import androidx.compose.material3.SheetValue import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable @@ -132,8 +130,12 @@ private fun DateBottomSheetContent( Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_chevron_left_20), contentDescription = null, - tint = colors.icon.default, - modifier = Modifier.noRippleClickable(onClick = onPrevMonthClick), + tint = if (uiState.hasPreviousMonth) colors.icon.default else colors.icon.disabled, + modifier = if (uiState.hasPreviousMonth) { + Modifier.noRippleClickable(onClick = onPrevMonthClick) + } else { + Modifier + }, ) Text( @@ -146,8 +148,12 @@ private fun DateBottomSheetContent( Icon( imageVector = ImageVector.vectorResource(R.drawable.ic_arrow_chevron_right_20), contentDescription = null, - tint = colors.icon.default, - modifier = Modifier.noRippleClickable(onClick = onNextMonthClick), + tint = if (uiState.hasNextMonth) colors.icon.default else colors.icon.disabled, + modifier = if (uiState.hasNextMonth) { + Modifier.noRippleClickable(onClick = onNextMonthClick) + } else { + Modifier + }, ) } @@ -187,7 +193,7 @@ private fun DateBottomSheetContent( week.forEach { calendarDay -> CalendarDayCell( calendarDay = calendarDay, - isSelected = calendarDay.isCurrentMonth && calendarDay.day == uiState.pendingDay, + isSelected = calendarDay.isSelectable && calendarDay.day == uiState.pendingDay, onClick = { onDayClick(calendarDay.day) }, modifier = Modifier.weight(1f), ) @@ -276,7 +282,7 @@ private fun CalendarDayCell( modifier = modifier .aspectRatio(1f) .then( - if (calendarDay.isCurrentMonth) { + if (calendarDay.isSelectable) { Modifier.noRippleClickable(onClick = onClick) } else { Modifier @@ -288,7 +294,7 @@ private fun CalendarDayCell( text = calendarDay.day.toString(), color = when { isSelected -> colors.text.onBg - calendarDay.isCurrentMonth -> colors.text.default + calendarDay.isSelectable -> colors.text.default else -> colors.text.tertiary }, style = typography.label.mdM, diff --git a/app/src/main/java/com/moive/app/presentation/home/HomeContract.kt b/app/src/main/java/com/moive/app/presentation/home/HomeContract.kt index efafcef4..54d7b4af 100644 --- a/app/src/main/java/com/moive/app/presentation/home/HomeContract.kt +++ b/app/src/main/java/com/moive/app/presentation/home/HomeContract.kt @@ -11,7 +11,7 @@ import kotlinx.collections.immutable.toImmutableList interface HomeContract { @Immutable data class State( - val userName: String = "수현", + val userName: String = "", val isAlarmUnRead: Boolean = false, val selectedTab: String = MeetingTab.ALL.label, val homeUiState: HomeUiState = HomeUiState.Idle, diff --git a/app/src/main/java/com/moive/app/presentation/home/HomeViewModel.kt b/app/src/main/java/com/moive/app/presentation/home/HomeViewModel.kt index b825c364..751bf384 100644 --- a/app/src/main/java/com/moive/app/presentation/home/HomeViewModel.kt +++ b/app/src/main/java/com/moive/app/presentation/home/HomeViewModel.kt @@ -35,6 +35,7 @@ class HomeViewModel @Inject constructor( _uiState.update { it.copy( homeUiState = HomeUiState.Success, + userName = home.nickname, upcomingMeetings = home.confirmedMeetings, myMeetingList = home.myMeetings, ) diff --git a/app/src/main/java/com/moive/app/presentation/meeting/confirmed/MeetingConfirmedViewModel.kt b/app/src/main/java/com/moive/app/presentation/meeting/confirmed/MeetingConfirmedViewModel.kt index 62afc51e..a603edc1 100644 --- a/app/src/main/java/com/moive/app/presentation/meeting/confirmed/MeetingConfirmedViewModel.kt +++ b/app/src/main/java/com/moive/app/presentation/meeting/confirmed/MeetingConfirmedViewModel.kt @@ -84,63 +84,59 @@ class MeetingConfirmedViewModel @Inject constructor( } } - private fun fetchPlaceDetail(areaId: Long, placeId: Long) { + private suspend fun fetchPlaceDetail(areaId: Long, placeId: Long) { _uiState.update { it.copy(placeDetailUiState = PlaceDetailUiState.Loading) } - viewModelScope.launch { - votingRepository.getRecommendedPlaceDetail( - meetingId = meetingId, - recommendedAreaId = areaId, - recommendedPlaceId = placeId, - current = _uiState.value.currentPlaceDetail, - ) - .onSuccess { updated -> - _uiState.update { - it.copy( - placeDetailUiState = PlaceDetailUiState.Success, - currentPlaceDetail = updated, - ) - } + votingRepository.getRecommendedPlaceDetail( + meetingId = meetingId, + recommendedAreaId = areaId, + recommendedPlaceId = placeId, + current = _uiState.value.currentPlaceDetail, + ) + .onSuccess { updated -> + _uiState.update { + it.copy( + placeDetailUiState = PlaceDetailUiState.Success, + currentPlaceDetail = updated, + ) } - .onFailure { error -> - Timber.tag(TAG).e(error, PLACE_DETAIL_FAILURE_MESSAGE) - _uiState.update { - it.copy( - placeDetailUiState = PlaceDetailUiState.Failure( - error.message ?: UNKNOWN_ERROR_MESSAGE, - ), - ) - } + } + .onFailure { error -> + Timber.tag(TAG).e(error, PLACE_DETAIL_FAILURE_MESSAGE) + _uiState.update { + it.copy( + placeDetailUiState = PlaceDetailUiState.Failure( + error.message ?: UNKNOWN_ERROR_MESSAGE, + ), + ) } - } + } } - private fun fetchPlaceRoute(placeId: Long) { + private suspend fun fetchPlaceRoute(placeId: Long) { _uiState.update { it.copy(placeRouteUiState = PlaceRouteUiState.Loading) } - viewModelScope.launch { - votingRepository.getRecommendedPlaceRoute( - meetingId = meetingId, - recommendedPlaceId = placeId, - current = _uiState.value.currentPlaceDetail, - ) - .onSuccess { updated -> - _uiState.update { - it.copy( - placeRouteUiState = PlaceRouteUiState.Success, - currentPlaceDetail = updated, - ) - } + votingRepository.getRecommendedPlaceRoute( + meetingId = meetingId, + recommendedPlaceId = placeId, + current = _uiState.value.currentPlaceDetail, + ) + .onSuccess { updated -> + _uiState.update { + it.copy( + placeRouteUiState = PlaceRouteUiState.Success, + currentPlaceDetail = updated, + ) } - .onFailure { error -> - Timber.tag(TAG).e(error, PLACE_ROUTE_FAILURE_MESSAGE) - _uiState.update { - it.copy( - placeRouteUiState = PlaceRouteUiState.Failure( - error.message ?: UNKNOWN_ERROR_MESSAGE, - ), - ) - } + } + .onFailure { error -> + Timber.tag(TAG).e(error, PLACE_ROUTE_FAILURE_MESSAGE) + _uiState.update { + it.copy( + placeRouteUiState = PlaceRouteUiState.Failure( + error.message ?: UNKNOWN_ERROR_MESSAGE, + ), + ) } - } + } } fun backToMain() { diff --git a/app/src/main/java/com/moive/app/presentation/voting/VotingViewModel.kt b/app/src/main/java/com/moive/app/presentation/voting/VotingViewModel.kt index d7c8587f..5e9a4956 100644 --- a/app/src/main/java/com/moive/app/presentation/voting/VotingViewModel.kt +++ b/app/src/main/java/com/moive/app/presentation/voting/VotingViewModel.kt @@ -135,6 +135,9 @@ class VotingViewModel @Inject constructor( step = Step.DETAIL, currentPlaceId = placeId, isPlaceListVisible = false, + currentPlaceDetail = VotingContract.State().currentPlaceDetail, + recommendedPlaceDetailUiState = RecommendedPlaceDetailUiState.Loading, + recommendedPlaceRouteUiState = RecommendedPlaceRouteUiState.Loading, ) } @@ -215,16 +218,18 @@ class VotingViewModel @Inject constructor( fun onSelectButtonClick() { _uiState.update { state -> - val viewingPlaceId = state.currentPlaceId ?: return@update state.copy(step = Step.RECOMMENDATION) + val viewingPlaceId = state.currentPlaceId + ?: return@update state.copy(step = Step.RECOMMENDATION, isPlaceListVisible = true) state.copy( step = Step.RECOMMENDATION, + isPlaceListVisible = true, selectedPlaceList = state.selectedPlaceList.add(viewingPlaceId), ) } } fun backToPlaceList() { - _uiState.update { it.copy(step = Step.RECOMMENDATION) } + _uiState.update { it.copy(step = Step.RECOMMENDATION, isPlaceListVisible = true) } } fun onKakaoMapRouteOpened(opened: Boolean) { diff --git a/app/src/main/java/com/moive/app/presentation/voting/component/PlaceListContent.kt b/app/src/main/java/com/moive/app/presentation/voting/component/PlaceListContent.kt index e9288a40..9c346abc 100644 --- a/app/src/main/java/com/moive/app/presentation/voting/component/PlaceListContent.kt +++ b/app/src/main/java/com/moive/app/presentation/voting/component/PlaceListContent.kt @@ -20,6 +20,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.res.vectorResource import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp @@ -70,6 +71,8 @@ fun PlaceListContent( ) { var kakaoMapState by remember { mutableStateOf(null) } var regionPinEntries by remember { mutableStateOf>>(emptyList()) } + var bottomSheetHeightPx by remember { mutableStateOf(0) } + var pendingCameraTarget by remember { mutableStateOf(null) } val regionPinBitmaps = List(regionList.size) { index -> rememberRegionPinBitmap(rank = index) } @@ -80,13 +83,10 @@ fun PlaceListContent( locationX = mapCenterX, locationY = mapCenterY, onMapReady = { kakaoMap -> - kakaoMap.setOnLabelClickListener { map, _, label -> + kakaoMap.setOnLabelClickListener { _, _, label -> val clickedRegion = regionPinEntries.firstOrNull { it.first == label }?.second - map.moveCamera( - CameraUpdateFactory.newCenterPosition(label.position, 16), - CameraAnimation.from(300), - ) + pendingCameraTarget = label.position clickedRegion?.let { onRegionPinClick(it) } true } @@ -116,6 +116,25 @@ fun PlaceListContent( } } + LaunchedEffect(kakaoMapState, isPlaceListVisible, bottomSheetHeightPx, places, pendingCameraTarget) { + val kakaoMap = kakaoMapState ?: return@LaunchedEffect + val bottomPadding = if (isPlaceListVisible) { + (bottomSheetHeightPx - PIN_DOWN_ADJUST_PX).coerceAtLeast(0) + } else { + 0 + } + kakaoMap.setPadding(0, 0, 0, bottomPadding) + + val target = pendingCameraTarget ?: return@LaunchedEffect + if (places.isEmpty()) return@LaunchedEffect + + kakaoMap.moveCamera( + CameraUpdateFactory.newCenterPosition(target, 16), + CameraAnimation.from(300), + ) + pendingCameraTarget = null + } + Box( modifier = modifier .fillMaxSize() @@ -141,6 +160,9 @@ fun PlaceListContent( title = "추천 장소", onDismissRequest = onBottomSheetDismiss, showScrim = false, + modifier = Modifier.onGloballyPositioned { coordinates -> + bottomSheetHeightPx = coordinates.size.height + }, content = { LazyColumn ( contentPadding = PaddingValues(vertical = 12.dp), @@ -190,6 +212,7 @@ fun PlaceListContent( private const val DEFAULT_LOCATION_X = 127.0246 // 신논현역 private const val DEFAULT_LOCATION_Y = 37.5044 // 신논현역 private const val MAP_BOUNDS_PADDING_PX = 300 +private const val PIN_DOWN_ADJUST_PX = 350 @Preview @Composable