diff --git a/app/src/main/java/com/wafflestudio/snutt2/data/SNUTTStorage.kt b/app/src/main/java/com/wafflestudio/snutt2/data/SNUTTStorage.kt index 37edcd21f..977c3ea48 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/data/SNUTTStorage.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/data/SNUTTStorage.kt @@ -169,6 +169,16 @@ class SNUTTStorage @Inject constructor( ), ) + val recentSearchedDepartments = PrefValue>( + prefContext, + PrefListValueMetaData( + domain = DOMAIN_SCOPE_LOGIN, + key = "recent_searched_departments", + type = TagDto::class.java, + defaultValue = listOf(), + ), + ) + fun clearLoginScope() { prefContext.clear(DOMAIN_SCOPE_LOGIN) prefContext.clear(DOMAIN_SCOPE_CURRENT_VERSION) diff --git a/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepository.kt b/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepository.kt index 9ff434bf7..98ec0eb46 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepository.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepository.kt @@ -6,9 +6,12 @@ import com.wafflestudio.snutt2.lib.network.dto.core.LectureDto import com.wafflestudio.snutt2.model.SearchTimeDto import com.wafflestudio.snutt2.model.TagDto import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow interface LectureSearchRepository { + val recentSearchedDepartments: StateFlow> + fun getLectureSearchResultStream( year: Long, semester: Long, @@ -23,4 +26,8 @@ interface LectureSearchRepository { suspend fun getBuildings( places: String, ): List + + fun storeRecentSearchedDepartment(tag: TagDto) + + fun removeRecentSearchedDepartment(tag: TagDto) } diff --git a/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepositoryImpl.kt b/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepositoryImpl.kt index b123e713a..a76b145d4 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepositoryImpl.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/data/lecture_search/LectureSearchRepositoryImpl.kt @@ -3,6 +3,7 @@ package com.wafflestudio.snutt2.data.lecture_search import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData +import com.wafflestudio.snutt2.data.SNUTTStorage import com.wafflestudio.snutt2.lib.SnuttUrls import com.wafflestudio.snutt2.lib.network.SNUTTRestApi import com.wafflestudio.snutt2.lib.network.dto.core.LectureBuildingDto @@ -18,8 +19,11 @@ import javax.inject.Singleton class LectureSearchRepositoryImpl @Inject constructor( private val api: SNUTTRestApi, private val snuttUrls: SnuttUrls, + private val storage: SNUTTStorage, ) : LectureSearchRepository { + override val recentSearchedDepartments = storage.recentSearchedDepartments.asStateFlow() + override fun getLectureSearchResultStream( year: Long, semester: Long, @@ -66,6 +70,19 @@ class LectureSearchRepositoryImpl @Inject constructor( return response.content } + override fun storeRecentSearchedDepartment(tag: TagDto) { + val previousStoredTags = storage.recentSearchedDepartments.get() + + storage.recentSearchedDepartments.update( + (previousStoredTags.filter { it != tag } + tag).takeLast(5), + ) + } + + override fun removeRecentSearchedDepartment(tag: TagDto) { + val previousStoredTags = storage.recentSearchedDepartments.get() + storage.recentSearchedDepartments.update(previousStoredTags - tag) + } + companion object { const val LECTURES_LOAD_PAGE_SIZE = 30 } diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchPage.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchPage.kt index cc08b866f..afb67eb41 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchPage.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchPage.kt @@ -169,9 +169,13 @@ fun SearchPage( launchSuspendApi(apiOnProgress, apiOnError) { searchViewModel.query() } + searchViewModel.storeRecentSearchedDepartments() } scope.launch { bottomSheet.hide() } }, + hideBottomSheet = { + scope.launch { bottomSheet.hide() } + }, draggedTimeBlock = draggedTimeBlock, ) } diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchViewModel.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchViewModel.kt index 74163e658..2ce8e79aa 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchViewModel.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/SearchViewModel.kt @@ -31,6 +31,7 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest @@ -88,6 +89,8 @@ class SearchViewModel @Inject constructor( // 검색 쿼리에 들어가는 시간대 검색 시간대 목록 private val _searchTimeList = MutableStateFlow?>(null) + val recentSearchedDepartments: StateFlow> = lectureSearchRepository.recentSearchedDepartments + init { viewModelScope.launch { semesterChange.distinctUntilChanged().collectLatest { @@ -113,6 +116,19 @@ class SearchViewModel @Inject constructor( .map { it.toDataWithState(selectedTags.contains(it)) } }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(), emptyList()) + val selectableRecentSearchedDepartments: StateFlow>> = combine( + tagsByTagType, recentSearchedDepartments, _selectedTags, + ) { tagsByTagType, recentDepartments, selectedTags -> + val tagItemsByTagType = tagsByTagType.map { tag -> tag.item } + recentDepartments.filter { recentDepartment -> + tagItemsByTagType.contains(recentDepartment) + }.map { it.toDataWithState(selectedTags.contains(it)) } + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + emptyList(), + ) + val bookmarkList = combine( _getBookmarkListSignal.flatMapLatest { try { @@ -254,6 +270,16 @@ class SearchViewModel @Inject constructor( } } + fun storeRecentSearchedDepartments() { + _selectedTags.value.filter { it.type == TagType.DEPARTMENT }.forEach { tag -> + lectureSearchRepository.storeRecentSearchedDepartment(tag) + } + } + + fun removeRecentSearchedDepartment(tag: TagDto) { + lectureSearchRepository.removeRecentSearchedDepartment(tag) + } + private suspend fun clear() { _searchTitle.emit("") _selectedLecture.emit(null) diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheet.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheet.kt index 11157cadd..e2a861461 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheet.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheet.kt @@ -2,6 +2,7 @@ package com.wafflestudio.snutt2.views.logged_in.home.search.search_option import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -14,7 +15,10 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.layout.SubcomposeLayout +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel +import com.wafflestudio.snutt2.components.compose.ExitIcon +import com.wafflestudio.snutt2.components.compose.clicks import com.wafflestudio.snutt2.model.TagDto import com.wafflestudio.snutt2.ui.SNUTTColors import com.wafflestudio.snutt2.views.logged_in.home.search.SearchViewModel @@ -28,11 +32,13 @@ private enum class OptionSheetMode { @Composable fun SearchOptionSheet( applyOption: () -> Unit, + hideBottomSheet: () -> Unit, draggedTimeBlock: State>>, ) { val viewModel = hiltViewModel() val tagsByTagType by viewModel.tagsByTagType.collectAsState() val selectedTagType by viewModel.selectedTagType.collectAsState() + val recentSearchedDepartments by viewModel.selectableRecentSearchedDepartments.collectAsState() val scope = rememberCoroutineScope() var optionSheetMode by remember { @@ -79,6 +85,7 @@ fun SearchOptionSheet( // tag column의 높이를 tagType column의 높이로 설정 height = tagTypePlaceable.height.toDp(), ), + recentSearchedDepartments = recentSearchedDepartments, tagsByTagType = tagsByTagType, selectedTimes = draggedTimeBlock, baseAnimatedFloat = baseAnimatedFloat, @@ -92,6 +99,9 @@ fun SearchOptionSheet( viewModel.toggleTag(it) } }, + onRemoveRecent = { + viewModel.removeRecentSearchedDepartment(it) + }, openTimeSelectSheet = { optionSheetMode = OptionSheetMode.TimeSelect }, @@ -128,6 +138,14 @@ fun SearchOptionSheet( SearchOptionConfirmButton(baseAnimatedFloat, applyOption) }.first().measure(constraints) + val closeBottomSheetPlaceable = subcompose(slotId = 5) { + Row( + modifier = Modifier.clicks { hideBottomSheet() }, + ) { + ExitIcon() + } + }.first().measure(constraints) + // 한번만 계산, 할당 if (normalSheetHeightPx == 0 && maxSheetHeightPx == 0) { normalSheetHeightPx = @@ -153,6 +171,10 @@ fun SearchOptionSheet( tagTypePlaceable.height + SearchOptionSheetConstants.TopMargin.toPx() .roundToInt(), ) + closeBottomSheetPlaceable.placeRelative( + tagTypePlaceable.width + tagListPlaceable.width - 52.dp.toPx().roundToInt(), + (SearchOptionSheetConstants.TopMargin.toPx().roundToInt() - 32.dp.toPx().roundToInt()) / 2, + ) if (baseAnimatedFloat.value != 0f) dragSheetPlaceable.placeRelative(0, 0) } } diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheetConstants.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheetConstants.kt index a46c975d4..91142a691 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheetConstants.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/SearchOptionSheetConstants.kt @@ -11,7 +11,7 @@ import com.wafflestudio.snutt2.ui.isDarkMode object SearchOptionSheetConstants { const val TagColumnWidthDp = 120 const val MaxHeightRatio = 0.85f - val TopMargin = 40.dp + val TopMargin = 68.dp val AnimationSpec = spring( visibilityThreshold = 1f, stiffness = 200f, diff --git a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/TagsColumn.kt b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/TagsColumn.kt index 261fa246c..b4ede6372 100644 --- a/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/TagsColumn.kt +++ b/app/src/main/java/com/wafflestudio/snutt2/views/logged_in/home/search/search_option/TagsColumn.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.material.Divider import androidx.compose.material.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.State @@ -22,6 +23,8 @@ import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.wafflestudio.snutt2.components.compose.ExitIcon import com.wafflestudio.snutt2.components.compose.VividCheckedIcon import com.wafflestudio.snutt2.components.compose.VividUncheckedIcon import com.wafflestudio.snutt2.components.compose.clicks @@ -33,14 +36,15 @@ import com.wafflestudio.snutt2.ui.SNUTTTypography @Composable fun TagsColumn( modifier: Modifier, + recentSearchedDepartments: List>, tagsByTagType: List>, selectedTimes: State>>, baseAnimatedFloat: State, onToggleTag: (TagDto) -> Unit, + onRemoveRecent: (TagDto) -> Unit, openTimeSelectSheet: () -> Unit, ) { val configuration = LocalConfiguration.current - val context = LocalContext.current val alphaAnimatedFloat = 1f - baseAnimatedFloat.value val offsetXAnimatedDp = (configuration.screenWidthDp - SearchOptionSheetConstants.TagColumnWidthDp).dp * baseAnimatedFloat.value @@ -49,46 +53,112 @@ fun TagsColumn( .offset(x = offsetXAnimatedDp) .alpha(alphaAnimatedFloat) .padding(horizontal = 20.dp, vertical = 10.dp), - verticalArrangement = Arrangement.spacedBy(20.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), // FIXME: rememberLazyListState() 넣으면 오류 ) { - items(tagsByTagType) { - Column { + if (recentSearchedDepartments.isNotEmpty()) { + item { + Text( + text = "최근 찾아본 학과", + style = SNUTTTypography.body1.copy( + fontSize = 13.sp, + color = SNUTTColors.Gray600, + ), + ) + } + + items(recentSearchedDepartments.reversed()) { departmentTag -> + SelectableTagItem( + selectableTag = departmentTag, + selectedTimes = selectedTimes, + onToggleTag = onToggleTag, + onRemoveRecent = onRemoveRecent, + openTimeSelectSheet = openTimeSelectSheet, + ) + } + + item { + Divider( + modifier = Modifier + .padding(top = 2.dp, bottom = 8.dp), + thickness = 0.5f.dp, + color = SNUTTColors.Gray200, + ) + } + } + + items(tagsByTagType) { tag -> + SelectableTagItem( + selectableTag = tag, + selectedTimes = selectedTimes, + onToggleTag = onToggleTag, + openTimeSelectSheet = openTimeSelectSheet, + ) + } + } +} + +@Composable +fun SelectableTagItem( + selectableTag: Selectable, + selectedTimes: State>>, + onToggleTag: (TagDto) -> Unit, + onRemoveRecent: ((TagDto) -> Unit)? = null, + openTimeSelectSheet: () -> Unit, +) { + val context = LocalContext.current + + Column( + modifier = Modifier.padding(bottom = 6.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + modifier = Modifier + .clicks { onToggleTag(selectableTag.item) } + .weight(.1f), + verticalAlignment = Alignment.CenterVertically, + ) { + if (selectableTag.state) { + VividCheckedIcon(modifier = Modifier.size(15.dp)) + } else { + VividUncheckedIcon(modifier = Modifier.size(15.dp)) + } + Spacer(modifier = Modifier.width(10.dp)) + Text( + text = selectableTag.item.name, + style = SNUTTTypography.body1, + ) + } + + if (onRemoveRecent != null) { Row( modifier = Modifier - .clicks { onToggleTag(it.item) }, + .clicks { onRemoveRecent(selectableTag.item) }, verticalAlignment = Alignment.CenterVertically, ) { - if (it.state) { - VividCheckedIcon(modifier = Modifier.size(15.dp)) - } else { - VividUncheckedIcon(modifier = Modifier.size(15.dp)) - } - Spacer(modifier = Modifier.width(10.dp)) + ExitIcon(modifier = Modifier.size(18.dp)) + } + } + } + if (selectableTag.item == TagDto.TIME_SELECT) { + Spacer(modifier = Modifier.height(6.dp)) + timeSlotsToFormattedString(context, selectedTimes.value).let { + if (it.isNotEmpty()) { Text( - text = it.item.name, - style = SNUTTTypography.body1, + text = it, + modifier = Modifier + .padding(start = 25.dp) + .clicks { + openTimeSelectSheet() + }, + style = SNUTTTypography.body2.copy( + color = SNUTTColors.Gray600, + textDecoration = TextDecoration.Underline, + ), ) } - if (it.item == TagDto.TIME_SELECT) { - Spacer(modifier = Modifier.height(6.dp)) - timeSlotsToFormattedString(context, selectedTimes.value).let { - if (it.isNotEmpty()) { - Text( - text = it, - modifier = Modifier - .padding(start = 25.dp) - .clicks { - openTimeSelectSheet() - }, - style = SNUTTTypography.body2.copy( - color = SNUTTColors.Gray600, - textDecoration = TextDecoration.Underline, - ), - ) - } - } - } } } }