diff --git a/core/designsystem/src/main/java/com/susu/core/designsystem/component/searchbar/SusuSearchBar.kt b/core/designsystem/src/main/java/com/susu/core/designsystem/component/searchbar/SusuSearchBar.kt index 15d2bad1..c001bc35 100644 --- a/core/designsystem/src/main/java/com/susu/core/designsystem/component/searchbar/SusuSearchBar.kt +++ b/core/designsystem/src/main/java/com/susu/core/designsystem/component/searchbar/SusuSearchBar.kt @@ -68,12 +68,12 @@ fun SusuSearchBar( decorationBox = { innerText -> Row( modifier = Modifier + .clip(RoundedCornerShape(4.dp)) .background(Gray20) .padding( horizontal = SusuTheme.spacing.spacing_m, vertical = SusuTheme.spacing.spacing_xxs, - ) - .clip(RoundedCornerShape(4.dp)), + ), verticalAlignment = Alignment.CenterVertically, ) { Image( diff --git a/core/model/src/main/java/com/susu/core/model/EnvelopeFilterConfig.kt b/core/model/src/main/java/com/susu/core/model/EnvelopeFilterConfig.kt new file mode 100644 index 00000000..6ec162fe --- /dev/null +++ b/core/model/src/main/java/com/susu/core/model/EnvelopeFilterConfig.kt @@ -0,0 +1,8 @@ +package com.susu.core.model + +data class EnvelopeFilterConfig( + val minReceivedAmount: Long, + val maxReceivedAmount: Long, + val minSentAmount: Long, + val maxSentAmount: Long, +) diff --git a/core/ui/build.gradle.kts b/core/ui/build.gradle.kts index 6232672f..61a0e260 100644 --- a/core/ui/build.gradle.kts +++ b/core/ui/build.gradle.kts @@ -10,6 +10,7 @@ android { } dependencies { + implementation(projects.core.model) implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.immutable) } diff --git a/core/ui/src/main/java/com/susu/core/ui/argument/EnvelopeFilterArgument.kt b/core/ui/src/main/java/com/susu/core/ui/argument/EnvelopeFilterArgument.kt new file mode 100644 index 00000000..e215a71a --- /dev/null +++ b/core/ui/src/main/java/com/susu/core/ui/argument/EnvelopeFilterArgument.kt @@ -0,0 +1,12 @@ +package com.susu.core.ui.argument + +import com.susu.core.model.Friend +import kotlinx.serialization.Serializable + +@Serializable +data class EnvelopeFilterArgument( + val selectedFriendList: List = emptyList(), + val fromAmount: Long? = null, + val toAmount: Long? = null, + val isSent: Boolean = false, +) diff --git a/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt b/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt index a3cb638d..a472853e 100644 --- a/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt +++ b/core/ui/src/main/java/com/susu/core/ui/extension/Long.kt @@ -6,3 +6,8 @@ fun Long.toMoneyFormat(): String { // DecimalFormat은 Thread Safe하지 않으므로 지역 변수로 사용함. return DecimalFormat("#,###").format(this) } + +fun Float.toMoneyFormat(): String { + // DecimalFormat은 Thread Safe하지 않으므로 지역 변수로 사용함. + return DecimalFormat("#,###").format(this) +} diff --git a/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt b/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt index 3ba030dc..03f08459 100644 --- a/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt +++ b/data/src/main/java/com/susu/data/data/repository/EnvelopesRepositoryImpl.kt @@ -2,6 +2,7 @@ package com.susu.data.data.repository import com.susu.core.model.Envelope import com.susu.core.model.EnvelopeDetail +import com.susu.core.model.EnvelopeFilterConfig import com.susu.core.model.EnvelopeSearch import com.susu.core.model.FriendStatistics import com.susu.core.model.Relationship @@ -150,6 +151,12 @@ class EnvelopesRepositoryImpl @Inject constructor( ), ).getOrThrow().toModel() + override suspend fun getEnvelopeFilterConfig(): EnvelopeFilterConfig = + envelopesService + .getEnvelopeFilterConfig() + .getOrThrow() + .toModel() + override suspend fun getEnvelopeDetail( id: Long, ): EnvelopeDetail = envelopesService.getEnvelopeDetail( diff --git a/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt b/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt index 26b32247..0e8aca80 100644 --- a/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt +++ b/data/src/main/java/com/susu/data/remote/api/EnvelopesService.kt @@ -2,6 +2,7 @@ package com.susu.data.remote.api import com.susu.data.remote.model.request.EnvelopeRequest import com.susu.data.remote.model.response.EnvelopeDetailResponse +import com.susu.data.remote.model.response.EnvelopeFilterConfigResponse import com.susu.data.remote.model.response.EnvelopeResponse import com.susu.data.remote.model.response.EnvelopesHistoryListResponse import com.susu.data.remote.model.response.EnvelopesListResponse @@ -81,4 +82,7 @@ interface EnvelopesService { @Query("size") size: Int?, @Query("sort") sort: String?, ): ApiResult + + @GET("envelopes/configs/search-filter") + suspend fun getEnvelopeFilterConfig(): ApiResult } diff --git a/data/src/main/java/com/susu/data/remote/model/response/EnvelopeFilterConfigResponse.kt b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeFilterConfigResponse.kt new file mode 100644 index 00000000..6c5b3795 --- /dev/null +++ b/data/src/main/java/com/susu/data/remote/model/response/EnvelopeFilterConfigResponse.kt @@ -0,0 +1,19 @@ +package com.susu.data.remote.model.response + +import com.susu.core.model.EnvelopeFilterConfig +import kotlinx.serialization.Serializable + +@Serializable +data class EnvelopeFilterConfigResponse( + val minReceivedAmount: Long, + val maxReceivedAmount: Long, + val minSentAmount: Long, + val maxSentAmount: Long, +) + +internal fun EnvelopeFilterConfigResponse.toModel() = EnvelopeFilterConfig( + minReceivedAmount = minReceivedAmount, + maxReceivedAmount = maxReceivedAmount, + minSentAmount = minSentAmount, + maxSentAmount = maxSentAmount, +) diff --git a/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt b/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt index 929992c2..e002d581 100644 --- a/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt +++ b/domain/src/main/java/com/susu/domain/repository/EnvelopesRepository.kt @@ -2,6 +2,7 @@ package com.susu.domain.repository import com.susu.core.model.Envelope import com.susu.core.model.EnvelopeDetail +import com.susu.core.model.EnvelopeFilterConfig import com.susu.core.model.EnvelopeSearch import com.susu.core.model.FriendStatistics import com.susu.core.model.Relationship @@ -81,4 +82,6 @@ interface EnvelopesRepository { categoryId: Long? = null, customCategory: String? = null, ): Envelope + + suspend fun getEnvelopeFilterConfig(): EnvelopeFilterConfig } diff --git a/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeFilterConfigUseCase.kt b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeFilterConfigUseCase.kt new file mode 100644 index 00000000..356dd06a --- /dev/null +++ b/domain/src/main/java/com/susu/domain/usecase/envelope/GetEnvelopeFilterConfigUseCase.kt @@ -0,0 +1,13 @@ +package com.susu.domain.usecase.envelope + +import com.susu.core.common.runCatchingIgnoreCancelled +import com.susu.domain.repository.EnvelopesRepository +import javax.inject.Inject + +class GetEnvelopeFilterConfigUseCase @Inject constructor( + private val envelopesRepository: EnvelopesRepository, +) { + suspend operator fun invoke() = runCatchingIgnoreCancelled { + envelopesRepository.getEnvelopeFilterConfig() + } +} diff --git a/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt b/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt index e8490b33..deea8691 100644 --- a/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt +++ b/feature/navigator/src/main/java/com/susu/feature/navigator/MainNavigator.kt @@ -25,7 +25,7 @@ import com.susu.feature.mypage.navigation.navigateMyPageInfo import com.susu.feature.mypage.navigation.navigateMyPagePrivacyPolicy import com.susu.feature.mypage.navigation.navigateMyPageSocial import com.susu.feature.received.navigation.ReceivedRoute -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import com.susu.feature.received.navigation.navigateLedgerAdd import com.susu.feature.received.navigation.navigateLedgerDetail import com.susu.feature.received.navigation.navigateLedgerEdit @@ -36,6 +36,7 @@ import com.susu.feature.received.navigation.navigateReceivedEnvelopeAdd import com.susu.feature.received.navigation.navigateReceivedEnvelopeDetail import com.susu.feature.received.navigation.navigateReceivedEnvelopeEdit import com.susu.feature.sent.navigation.SentRoute +import com.susu.feature.sent.navigation.navigateEnvelopeFilter import com.susu.feature.sent.navigation.navigateSent import com.susu.feature.sent.navigation.navigateSentEnvelope import com.susu.feature.sent.navigation.navigateSentEnvelopeAdd @@ -62,9 +63,10 @@ internal class MainNavigator( get() = when (currentDestination?.route) { in listOf( ReceivedRoute.ledgerSearchRoute, - ReceivedRoute.ledgerFilterRoute("{${ReceivedRoute.FILTER_ARGUMENT_NAME}}"), + ReceivedRoute.ledgerFilterRoute("{${ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME}}"), ReceivedRoute.envelopeDetailRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", "{${ReceivedRoute.LEDGER_ID_ARGUMENT_NAME}}"), ReceivedRoute.envelopeEditRoute("{${ReceivedRoute.ENVELOPE_ARGUMENT_NAME}}", "{${ReceivedRoute.LEDGER_ID_ARGUMENT_NAME}}"), + SentRoute.envelopeFilterRoute("{${SentRoute.FILTER_ENVELOPE_ARGUMENT}}"), SentRoute.sentEnvelopeRoute("{${SentRoute.FRIEND_ID_ARGUMENT_NAME}}"), SentRoute.sentEnvelopeDetailRoute("{${SentRoute.ENVELOPE_ID_ARGUMENT_NAME}}"), SentRoute.sentEnvelopeEditRoute("{${SentRoute.ENVELOPE_DETAIL_ARGUMENT_NAME}}"), @@ -153,7 +155,7 @@ internal class MainNavigator( navController.navigateLedgerEdit(ledger) } - fun navigateLedgerFilter(filter: FilterArgument) { + fun navigateLedgerFilter(filter: LedgerFilterArgument) { navController.navigateLedgerFilter(filter) } @@ -197,6 +199,10 @@ internal class MainNavigator( navController.navigateVoteDetail(voteId) } + fun navigateEnvelopeFilter(filter: String) { + navController.navigateEnvelopeFilter(filter) + } + fun navigateVoteEdit(vote: Vote) { navController.navigateVoteEdit(vote) } diff --git a/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt b/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt index b2e4ae34..1c7abff0 100644 --- a/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt +++ b/feature/navigator/src/main/java/com/susu/feature/navigator/MainScreen.kt @@ -28,6 +28,7 @@ import com.susu.feature.loginsignup.navigation.loginSignupNavGraph import com.susu.feature.mypage.navigation.myPageNavGraph import com.susu.feature.received.navigation.ReceivedRoute import com.susu.feature.received.navigation.receivedNavGraph +import com.susu.feature.sent.navigation.SentRoute import com.susu.feature.sent.navigation.sentNavGraph import com.susu.feature.statistics.navigation.statisticsNavGraph import kotlinx.collections.immutable.ImmutableList @@ -84,6 +85,13 @@ internal fun MainScreen( navigateSentEnvelopeDetail = navigator::navigateSentEnvelopeDetail, navigateSentEnvelopeEdit = navigator::navigateSentEnvelopeEdit, navigateSentEnvelopeAdd = navigator::navigateSentEnvelopeAdd, + popBackStackWithFilter = { filter -> + navigator.navController.previousBackStackEntry?.savedStateHandle?.set( + SentRoute.FILTER_ENVELOPE_ARGUMENT, + filter, + ) + navigator.popBackStackIfNotHome() + }, navigateSentEnvelopeSearch = navigator::navigateSentEnvelopeSearch, handleException = viewModel::handleException, onShowSnackbar = viewModel::onShowSnackbar, @@ -92,6 +100,7 @@ internal fun MainScreen( receivedNavGraph( padding = innerPadding, + envelopeFilterArgumentName = SentRoute.FILTER_ENVELOPE_ARGUMENT, popBackStack = navigator::popBackStackIfNotHome, popBackStackWithLedger = { ledger -> navigator.navController.previousBackStackEntry?.savedStateHandle?.set( @@ -109,7 +118,7 @@ internal fun MainScreen( }, popBackStackWithFilter = { filter -> navigator.navController.previousBackStackEntry?.savedStateHandle?.set( - ReceivedRoute.FILTER_ARGUMENT_NAME, + ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME, filter, ) navigator.popBackStackIfNotHome() @@ -136,6 +145,7 @@ internal fun MainScreen( navigateEnvelopAdd = navigator::navigateReceivedEnvelopeAdd, navigateEnvelopeDetail = navigator::navigateReceivedEnvelopeDetail, navigateEnvelopeEdit = navigator::navigateReceivedEnvelopeEdit, + navigateEnvelopeFilter = navigator::navigateEnvelopeFilter, onShowSnackbar = viewModel::onShowSnackbar, onShowDialog = viewModel::onShowDialog, handleException = viewModel::handleException, diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt index d750f26c..9edfc116 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailContract.kt @@ -1,8 +1,11 @@ package com.susu.feature.received.ledgerdetail +import androidx.annotation.StringRes import com.susu.core.model.Envelope +import com.susu.core.model.Friend import com.susu.core.model.Ledger import com.susu.core.model.SearchEnvelope +import com.susu.core.ui.R import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState import kotlinx.collections.immutable.PersistentList @@ -16,7 +19,36 @@ data class LedgerDetailState( val startDate: String = "", val endDate: String = "", val envelopeList: PersistentList = persistentListOf(), -) : UiState + val selectedFriendList: PersistentList = persistentListOf(), + val fromAmount: Long? = null, + val toAmount: Long? = null, + val showAlignBottomSheet: Boolean = false, + val selectedAlignPosition: Int = EnvelopeAlign.RECENT.ordinal, +) : UiState { + val isFiltered = fromAmount != null || toAmount != null || selectedFriendList.isNotEmpty() +} + +enum class EnvelopeAlign( + @StringRes val stringResId: Int, + val query: String, +) { + RECENT( + stringResId = R.string.word_align_recently, + query = "createdAt,desc", + ), + OUTDATED( + stringResId = R.string.word_align_outdated, + query = "createdAt,asc", + ), + HIGH_AMOUNT( + stringResId = R.string.word_align_high_amount, + query = "totalReceivedAmounts,desc", + ), + LOW_AMOUNT( + stringResId = R.string.word_align_low_amount, + query = "totalReceivedAmounts,asc", + ), +} sealed interface LedgerDetailSideEffect : SideEffect { data class NavigateEnvelopeAdd(val ledger: Ledger) : LedgerDetailSideEffect @@ -28,4 +60,5 @@ sealed interface LedgerDetailSideEffect : SideEffect { data object ShowDeleteSuccessSnackbar : LedgerDetailSideEffect data class ShowSnackbar(val msg: String) : LedgerDetailSideEffect data class HandleException(val throwable: Throwable, val retry: () -> Unit) : LedgerDetailSideEffect + data class NavigateEnvelopeFilter(val filter: String) : LedgerDetailSideEffect } diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt index 7b512d6e..1d3915a0 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailScreen.kt @@ -2,6 +2,7 @@ package com.susu.feature.received.ledgerdetail import androidx.activity.compose.BackHandler import androidx.compose.foundation.background +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -17,6 +18,8 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -34,7 +37,11 @@ import com.susu.core.designsystem.component.appbar.SusuDefaultAppBar import com.susu.core.designsystem.component.appbar.icon.BackIcon import com.susu.core.designsystem.component.appbar.icon.DeleteText import com.susu.core.designsystem.component.appbar.icon.EditText +import com.susu.core.designsystem.component.bottomsheet.SusuSelectionBottomSheet +import com.susu.core.designsystem.component.button.FilledButtonColor +import com.susu.core.designsystem.component.button.FilterButton import com.susu.core.designsystem.component.button.GhostButtonColor +import com.susu.core.designsystem.component.button.SelectedFilterButton import com.susu.core.designsystem.component.button.SmallButtonStyle import com.susu.core.designsystem.component.button.SusuFloatingButton import com.susu.core.designsystem.component.button.SusuGhostButton @@ -42,20 +49,24 @@ import com.susu.core.designsystem.theme.Gray25 import com.susu.core.designsystem.theme.Gray50 import com.susu.core.designsystem.theme.SusuTheme import com.susu.core.model.Envelope +import com.susu.core.model.Friend import com.susu.core.model.Ledger import com.susu.core.ui.DialogToken import com.susu.core.ui.R import com.susu.core.ui.SnackbarToken -import com.susu.core.ui.alignList import com.susu.core.ui.extension.OnBottomReached import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.toMoneyFormat import com.susu.feature.received.ledgerdetail.component.LedgerDetailEnvelopeContainer import com.susu.feature.received.ledgerdetail.component.LedgerDetailOverviewColumn +import kotlinx.collections.immutable.toPersistentList @Composable fun LedgerDetailRoute( viewModel: LedgerDetailViewModel = hiltViewModel(), envelope: String?, + envelopeFilter: String?, + navigateEnvelopeFilter: (String) -> Unit, toDeleteEnvelopeId: Long?, navigateLedgerEdit: (Ledger) -> Unit, navigateEnvelopAdd: (Ledger) -> Unit, @@ -96,6 +107,7 @@ fun LedgerDetailRoute( is LedgerDetailSideEffect.PopBackStackWithDeleteLedgerId -> popBackStackWithDeleteLedgerId(sideEffect.ledgerId) is LedgerDetailSideEffect.HandleException -> handleException(sideEffect.throwable, sideEffect.retry) is LedgerDetailSideEffect.ShowSnackbar -> onShowSnackbar(SnackbarToken(message = sideEffect.msg)) + is LedgerDetailSideEffect.NavigateEnvelopeFilter -> navigateEnvelopeFilter(sideEffect.filter) is LedgerDetailSideEffect.NavigateEnvelopeAdd -> navigateEnvelopAdd(sideEffect.ledger) is LedgerDetailSideEffect.NavigateEnvelopeDetail -> navigateEnvelopeDetail(sideEffect.envelope, sideEffect.ledger) } @@ -107,6 +119,7 @@ fun LedgerDetailRoute( viewModel.addEnvelopeIfNeed(envelope) viewModel.deleteEnvelopeIfNeed(toDeleteEnvelopeId) viewModel.updateEnvelopeIfNeed(envelope) + viewModel.filterIfNeed(envelopeFilter) } listState.OnBottomReached(minItemsCount = 4) { @@ -127,9 +140,16 @@ fun LedgerDetailRoute( onClickFloatingButton = viewModel::navigateEnvelopeAdd, onClickSeeMoreIcon = viewModel::navigateEnvelopeDetail, onClickEnvelopeAddButton = viewModel::navigateEnvelopeAdd, + onClickFilterButton = viewModel::navigateEnvelopeFilter, + onClickCloseFriend = viewModel::removeFriend, + onClickCloseAmount = viewModel::clearAmount, + onClickAlignButton = viewModel::showAlignBottomSheet, + onDismissAlignBottomSheet = viewModel::hideAlignBottomSheet, + onClickAlignBottomSheetItem = viewModel::updateAlignBottomSheet, ) } +@OptIn(ExperimentalMaterial3Api::class) @Composable fun LedgerDetailScreen( uiState: LedgerDetailState = LedgerDetailState(), @@ -138,10 +158,14 @@ fun LedgerDetailScreen( onClickEdit: () -> Unit = {}, onClickDelete: () -> Unit = {}, onClickFilterButton: () -> Unit = {}, - onClickAlignButton: () -> Unit = {}, onClickEnvelopeAddButton: () -> Unit = {}, onClickFloatingButton: () -> Unit = {}, onClickSeeMoreIcon: (Envelope) -> Unit = {}, + onClickCloseFriend: (Friend) -> Unit = {}, + onClickCloseAmount: () -> Unit = {}, + onClickAlignButton: () -> Unit = {}, + onDismissAlignBottomSheet: () -> Unit = {}, + onClickAlignBottomSheetItem: (Int) -> Unit = {}, ) { Box( modifier = Modifier @@ -195,29 +219,15 @@ fun LedgerDetailScreen( item { Row( - modifier = Modifier.padding( - horizontal = SusuTheme.spacing.spacing_m, - ), + modifier = Modifier + .horizontalScroll(rememberScrollState()), horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), ) { + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) SusuGhostButton( color = GhostButtonColor.Black, style = SmallButtonStyle.height32, - text = stringResource(R.string.word_filter), - leftIcon = { - Icon( - modifier = Modifier.size(16.dp), - painter = painterResource(id = R.drawable.ic_filter), - contentDescription = null, - ) - }, - onClick = onClickFilterButton, - ) - - SusuGhostButton( - color = GhostButtonColor.Black, - style = SmallButtonStyle.height32, - text = alignList[0], // TODO State 변환 + text = stringResource(id = EnvelopeAlign.entries[uiState.selectedAlignPosition].stringResId), leftIcon = { Icon( painter = painterResource(id = R.drawable.ic_align), @@ -226,6 +236,28 @@ fun LedgerDetailScreen( }, onClick = onClickAlignButton, ) + + FilterButton(uiState.isFiltered, onClickFilterButton) + + uiState.selectedFriendList.forEach { friend -> + SelectedFilterButton( + color = FilledButtonColor.Black, + style = SmallButtonStyle.height32, + name = friend.name, + onClickCloseIcon = { onClickCloseFriend(friend) }, + ) + } + + if (uiState.fromAmount != null && uiState.toAmount != null) { + SelectedFilterButton( + color = FilledButtonColor.Black, + style = SmallButtonStyle.height32, + name = "${uiState.fromAmount.toMoneyFormat()}~${uiState.toAmount.toMoneyFormat()}", + onClickCloseIcon = { onClickCloseAmount() }, + ) + } + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) } } @@ -262,6 +294,16 @@ fun LedgerDetailScreen( } } + if (uiState.showAlignBottomSheet) { + SusuSelectionBottomSheet( + onDismissRequest = onDismissAlignBottomSheet, + containerHeight = 250.dp, + items = EnvelopeAlign.entries.map { stringResource(id = it.stringResId) }.toPersistentList(), + selectedItemPosition = uiState.selectedAlignPosition, + onClickItem = onClickAlignBottomSheetItem, + ) + } + SusuFloatingButton( modifier = Modifier .align(Alignment.BottomEnd) diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt index 129f66dc..9b7197b8 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerdetail/LedgerDetailViewModel.kt @@ -3,9 +3,11 @@ package com.susu.feature.received.ledgerdetail import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.susu.core.model.Envelope +import com.susu.core.model.Friend import com.susu.core.model.Ledger import com.susu.core.model.SearchEnvelope import com.susu.core.model.exception.NotFoundLedgerException +import com.susu.core.ui.argument.EnvelopeFilterArgument import com.susu.core.ui.base.BaseViewModel import com.susu.core.ui.extension.decodeFromUri import com.susu.core.ui.extension.encodeToUri @@ -36,8 +38,32 @@ class LedgerDetailViewModel @Inject constructor( private var page = 0 private var isLast = false + private var filter: EnvelopeFilterArgument = EnvelopeFilterArgument() + private var filterUri: String? = null + private var isFirstVisited: Boolean = true + fun filterIfNeed(filterUri: String?) { + if (filterUri == null) return + + if (this.filterUri == filterUri) return + this.filterUri = filterUri + + val envelopeFilterArgument = Json.decodeFromUri(filterUri) + if (filter == envelopeFilterArgument) return + + filter = envelopeFilterArgument + intent { + copy( + selectedFriendList = filter.selectedFriendList.toPersistentList(), + fromAmount = filter.fromAmount, + toAmount = filter.toAmount, + ) + } + + getReceivedEnvelopeList(true) + } + fun addEnvelopeIfNeed(envelopeUri: String?) = intent { val envelope = envelopeUri?.let { Json.decodeFromUri(it) @@ -139,12 +165,12 @@ class LedgerDetailViewModel @Inject constructor( searchReceivedEnvelopeListUseCase( param = SearchReceivedEnvelopeListUseCase.Param( - friendIds = null, + friendIds = currentState.selectedFriendList.map { it.id.toInt() }, ledgerId = ledger.id, - fromAmount = null, - toAmount = null, + fromAmount = currentState.fromAmount, + toAmount = currentState.toAmount, page = page, - sort = null, + sort = EnvelopeAlign.entries[currentState.selectedAlignPosition].query, ), ).onSuccess { envelopeList -> isLast = envelopeList.isEmpty() @@ -160,6 +186,31 @@ class LedgerDetailViewModel @Inject constructor( } } + fun removeFriend(friend: Friend) { + intent { + filter = filter.copy( + selectedFriendList = selectedFriendList.minus(friend), + ) + copy( + selectedFriendList = selectedFriendList.minus(friend).toPersistentList(), + ) + } + + getReceivedEnvelopeList(true) + } + + fun clearAmount() { + intent { + copy( + fromAmount = null, + toAmount = null, + ) + } + + getReceivedEnvelopeList(true) + } + + fun navigateEnvelopeFilter() = postSideEffect(LedgerDetailSideEffect.NavigateEnvelopeFilter(Json.encodeToUri(filter))) fun navigateLedgerEdit() = postSideEffect(LedgerDetailSideEffect.NavigateLedgerEdit(ledger)) fun popBackStackWithLedger() = postSideEffect(LedgerDetailSideEffect.PopBackStackWithLedger(Json.encodeToUri(ledger))) @@ -190,4 +241,17 @@ class LedgerDetailViewModel @Inject constructor( ) fun navigateEnvelopeDetail(envelope: Envelope) = postSideEffect(LedgerDetailSideEffect.NavigateEnvelopeDetail(envelope, ledger)) + fun showAlignBottomSheet() = intent { + copy(showAlignBottomSheet = true) + } + + fun hideAlignBottomSheet() = intent { + copy(showAlignBottomSheet = false) + } + + fun updateAlignBottomSheet(position: Int) { + intent { copy(selectedAlignPosition = position) } + getReceivedEnvelopeList(true) + hideAlignBottomSheet() + } } diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterContract.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterContract.kt index 05af5047..5741b5fa 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterContract.kt @@ -3,7 +3,7 @@ package com.susu.feature.received.ledgerfilter import com.susu.core.model.Category import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import kotlinx.datetime.toKotlinLocalDateTime @@ -18,7 +18,7 @@ data class LedgerFilterState( val showEndDateBottomSheet: Boolean = false, ) : UiState -internal fun LedgerFilterState.toFilterArgument() = FilterArgument( +internal fun LedgerFilterState.toFilterArgument() = LedgerFilterArgument( selectedCategoryList = selectedCategoryList, startAt = startAt?.toKotlinLocalDateTime(), endAt = endAt?.toKotlinLocalDateTime(), diff --git a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterViewModel.kt index 46144ac2..c93d2c0e 100644 --- a/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/ledgerfilter/LedgerFilterViewModel.kt @@ -8,7 +8,7 @@ import com.susu.core.ui.extension.decodeFromUri import com.susu.core.ui.extension.encodeToUri import com.susu.domain.usecase.categoryconfig.GetCategoryConfigUseCase import com.susu.feature.received.navigation.ReceivedRoute -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.minus import kotlinx.collections.immutable.persistentListOf @@ -27,8 +27,8 @@ class LedgerFilterViewModel @Inject constructor( ) : BaseViewModel( LedgerFilterState(), ) { - private val argument = savedStateHandle.get(ReceivedRoute.FILTER_ARGUMENT_NAME)!! - private var filter = FilterArgument() + private val argument = savedStateHandle.get(ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME)!! + private var filter = LedgerFilterArgument() fun initData() { initFilter() @@ -96,7 +96,7 @@ class LedgerFilterViewModel @Inject constructor( fun hideEndDateBottomSheet() = intent { copy(showEndDateBottomSheet = false) } fun popBackStack() = postSideEffect(LedgerFilterSideEffect.PopBackStack) fun popBackStackWithFilter() { - val filter = FilterArgument( + val filter = LedgerFilterArgument( selectedCategoryList = currentState.selectedCategoryList, startAt = currentState.startAt?.toKotlinLocalDateTime(), endAt = currentState.endAt?.toKotlinLocalDateTime(), diff --git a/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt b/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt index ed209b3a..46fade6f 100644 --- a/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt +++ b/feature/received/src/main/java/com/susu/feature/received/navigation/ReceivedNavigation.kt @@ -20,7 +20,7 @@ import com.susu.feature.received.ledgerdetail.LedgerDetailRoute import com.susu.feature.received.ledgeredit.LedgerEditRoute import com.susu.feature.received.ledgerfilter.LedgerFilterRoute import com.susu.feature.received.ledgersearch.LedgerSearchRoute -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import com.susu.feature.received.received.ReceivedRoute import kotlinx.serialization.json.Json @@ -40,7 +40,7 @@ fun NavController.navigateLedgerEdit(ledger: Ledger) { navigate(ReceivedRoute.ledgerEditRoute(Json.encodeToUri(ledger))) } -fun NavController.navigateLedgerFilter(filter: FilterArgument) { +fun NavController.navigateLedgerFilter(filter: LedgerFilterArgument) { navigate(ReceivedRoute.ledgerFilterRoute(Json.encodeToUri(filter))) } @@ -63,6 +63,7 @@ fun NavController.navigateReceivedEnvelopeEdit(envelope: Envelope, ledger: Ledge @Suppress("detekt:LongMethod") fun NavGraphBuilder.receivedNavGraph( padding: PaddingValues, + envelopeFilterArgumentName: String, navigateLedgerDetail: (Ledger) -> Unit, popBackStack: () -> Unit, popBackStackWithLedger: (String) -> Unit, @@ -70,8 +71,9 @@ fun NavGraphBuilder.receivedNavGraph( popBackStackWithFilter: (String) -> Unit, navigateLedgerSearch: () -> Unit, navigateLedgerEdit: (Ledger) -> Unit, - navigateLedgerFilter: (FilterArgument) -> Unit, + navigateLedgerFilter: (LedgerFilterArgument) -> Unit, navigateLedgerAdd: () -> Unit, + navigateEnvelopeFilter: (String) -> Unit, navigateEnvelopAdd: (Ledger) -> Unit, navigateEnvelopeDetail: (Envelope, Ledger) -> Unit, navigateEnvelopeEdit: (Envelope, Ledger) -> Unit, @@ -84,8 +86,8 @@ fun NavGraphBuilder.receivedNavGraph( composable(route = ReceivedRoute.route) { navBackStackEntry -> val ledger = navBackStackEntry.savedStateHandle.get(ReceivedRoute.LEDGER_ARGUMENT_NAME) val toDeleteLedgerId = navBackStackEntry.savedStateHandle.get(ReceivedRoute.LEDGER_ID_ARGUMENT_NAME) ?: -1 - val filter = navBackStackEntry.savedStateHandle.get(ReceivedRoute.FILTER_ARGUMENT_NAME) - navBackStackEntry.savedStateHandle.set(ReceivedRoute.FILTER_ARGUMENT_NAME, null) + val filter = navBackStackEntry.savedStateHandle.get(ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME) + navBackStackEntry.savedStateHandle.set(ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME, null) ReceivedRoute( ledger = ledger, toDeleteLedgerId = toDeleteLedgerId, @@ -108,8 +110,12 @@ fun NavGraphBuilder.receivedNavGraph( ) { navBackStackEntry -> val envelope = navBackStackEntry.savedStateHandle.get(ReceivedRoute.ENVELOPE_ARGUMENT_NAME) val toDeleteEnvelopeId = navBackStackEntry.savedStateHandle.get(ReceivedRoute.ENVELOPE_ID_ARGUMENT_NAME) + val envelopeFilter = navBackStackEntry.savedStateHandle.get(envelopeFilterArgumentName) + navBackStackEntry.savedStateHandle.set(envelopeFilterArgumentName, null) LedgerDetailRoute( envelope = envelope, + envelopeFilter = envelopeFilter, + navigateEnvelopeFilter = navigateEnvelopeFilter, toDeleteEnvelopeId = toDeleteEnvelopeId, navigateLedgerEdit = navigateLedgerEdit, navigateEnvelopAdd = navigateEnvelopAdd, @@ -137,7 +143,7 @@ fun NavGraphBuilder.receivedNavGraph( } composable( - route = ReceivedRoute.ledgerFilterRoute("{${ReceivedRoute.FILTER_ARGUMENT_NAME}}"), + route = ReceivedRoute.ledgerFilterRoute("{${ReceivedRoute.FILTER_LEDGER_ARGUMENT_NAME}}"), ) { LedgerFilterRoute( popBackStack = popBackStack, @@ -206,7 +212,7 @@ object ReceivedRoute { const val ENVELOPE_ARGUMENT_NAME = "envelope" const val ENVELOPE_ID_ARGUMENT_NAME = "envelope-id" - const val FILTER_ARGUMENT_NAME = "filter" + const val FILTER_LEDGER_ARGUMENT_NAME = "filter-ledger" fun ledgerDetailRoute(ledger: String) = "ledger-detail/$ledger" fun ledgerEditRoute(ledger: String) = "ledger-edit/$ledger" fun ledgerFilterRoute(filter: String) = "ledger-filter/$filter" diff --git a/feature/received/src/main/java/com/susu/feature/received/navigation/argument/FilterArgument.kt b/feature/received/src/main/java/com/susu/feature/received/navigation/argument/LedgerFilterArgument.kt similarity index 90% rename from feature/received/src/main/java/com/susu/feature/received/navigation/argument/FilterArgument.kt rename to feature/received/src/main/java/com/susu/feature/received/navigation/argument/LedgerFilterArgument.kt index adf8a8bf..6e214bc2 100644 --- a/feature/received/src/main/java/com/susu/feature/received/navigation/argument/FilterArgument.kt +++ b/feature/received/src/main/java/com/susu/feature/received/navigation/argument/LedgerFilterArgument.kt @@ -5,7 +5,7 @@ import kotlinx.datetime.LocalDateTime import kotlinx.serialization.Serializable @Serializable -data class FilterArgument( +data class LedgerFilterArgument( val selectedCategoryList: List = emptyList(), val startAt: LocalDateTime? = null, val endAt: LocalDateTime? = null, diff --git a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedContract.kt b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedContract.kt index 5ddbb046..05e226d3 100644 --- a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedContract.kt +++ b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedContract.kt @@ -6,7 +6,7 @@ import com.susu.core.model.Ledger import com.susu.core.ui.R import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import kotlinx.collections.immutable.PersistentList import kotlinx.collections.immutable.persistentListOf import java.time.LocalDateTime @@ -50,6 +50,6 @@ sealed interface ReceivedEffect : SideEffect { data class NavigateLedgerDetail(val ledger: Ledger) : ReceivedEffect data object NavigateLedgerAdd : ReceivedEffect data object NavigateLedgerSearch : ReceivedEffect - data class NavigateLedgerFilter(val filter: FilterArgument) : ReceivedEffect + data class NavigateLedgerFilter(val filter: LedgerFilterArgument) : ReceivedEffect data object ScrollToTop : ReceivedEffect } diff --git a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt index 6e4d56f7..ff557545 100644 --- a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt +++ b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedScreen.kt @@ -53,7 +53,7 @@ import com.susu.core.ui.extension.OnBottomReached import com.susu.core.ui.extension.collectWithLifecycle import com.susu.core.ui.util.to_yyyy_dot_MM_dot_dd import com.susu.feature.received.R -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import com.susu.feature.received.received.component.LedgerAddCard import com.susu.feature.received.received.component.LedgerCard import kotlinx.collections.immutable.toPersistentList @@ -72,7 +72,7 @@ fun ReceivedRoute( padding: PaddingValues, navigateLedgerDetail: (Ledger) -> Unit, navigateLedgerSearch: () -> Unit, - navigateLedgerFilter: (FilterArgument) -> Unit, + navigateLedgerFilter: (LedgerFilterArgument) -> Unit, navigateLedgerAdd: () -> Unit, ) { val uiState = viewModel.uiState.collectAsStateWithLifecycle().value diff --git a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedViewModel.kt b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedViewModel.kt index 0e1261fb..3eac3ad4 100644 --- a/feature/received/src/main/java/com/susu/feature/received/received/ReceivedViewModel.kt +++ b/feature/received/src/main/java/com/susu/feature/received/received/ReceivedViewModel.kt @@ -8,7 +8,7 @@ import com.susu.core.ui.extension.decodeFromUri import com.susu.core.ui.util.currentDate import com.susu.core.ui.util.isBetween import com.susu.domain.usecase.ledger.GetLedgerListUseCase -import com.susu.feature.received.navigation.argument.FilterArgument +import com.susu.feature.received.navigation.argument.LedgerFilterArgument import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.launch @@ -29,7 +29,7 @@ class ReceivedViewModel @Inject constructor( private var page = 0 private var isLast = false - private var filter: FilterArgument = FilterArgument() + private var filter: LedgerFilterArgument = LedgerFilterArgument() private var filterUri: String? = null private var isFirstVisit = true @@ -45,10 +45,10 @@ class ReceivedViewModel @Inject constructor( if (this.filterUri == filterUri) return this.filterUri = filterUri - val filterArgument = Json.decodeFromUri(filterUri) - if (filter == filterArgument) return + val ledgerFilterArgument = Json.decodeFromUri(filterUri) + if (filter == ledgerFilterArgument) return - filter = filterArgument + filter = ledgerFilterArgument intent { copy( selectedCategoryList = filter.selectedCategoryList.toPersistentList(), @@ -175,7 +175,7 @@ class ReceivedViewModel @Inject constructor( fun navigateLedgerFilter() = postSideEffect( ReceivedEffect.NavigateLedgerFilter( with(currentState) { - FilterArgument( + LedgerFilterArgument( selectedCategoryList = selectedCategoryList, startAt = startAt?.toKotlinLocalDateTime(), endAt = endAt?.toKotlinLocalDateTime(), diff --git a/feature/sent/build.gradle.kts b/feature/sent/build.gradle.kts index d9b3139b..4148cdad 100644 --- a/feature/sent/build.gradle.kts +++ b/feature/sent/build.gradle.kts @@ -1,6 +1,7 @@ @Suppress("DSL_SCOPE_VIOLATION") // TODO: Remove once KTIJ-19369 is fixed plugins { alias(libs.plugins.susu.android.feature.compose) + alias(libs.plugins.kotlin.serialization) } android { @@ -9,4 +10,5 @@ android { dependencies { implementation(libs.kotlinx.serialization.json) + implementation(libs.compose.toolbar) } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt index 2cba5ee8..2bcc7abf 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterContract.kt @@ -1,11 +1,23 @@ package com.susu.feature.envelopefilter +import com.susu.core.model.Friend import com.susu.core.ui.base.SideEffect import com.susu.core.ui.base.UiState +import kotlinx.collections.immutable.PersistentList +import kotlinx.collections.immutable.persistentListOf data class EnvelopeFilterState( - val temp: String = "", -) : UiState + val searchKeyword: String = "", + val selectedFriendList: PersistentList = persistentListOf(), + val friendList: PersistentList = persistentListOf(), + val fromAmount: Long? = null, + val toAmount: Long? = null, + val maxFromAmount: Long = 0, + val maxToAmount: Long = 0, +) : UiState { + val sliderValue = fromAmount?.toFloat()?.rangeTo(toAmount?.toFloat()!!) ?: maxFromAmount.toFloat()..maxToAmount.toFloat() + val sliderValueRange = maxFromAmount.toFloat()..maxToAmount.toFloat() +} sealed interface EnvelopeFilterSideEffect : SideEffect { data object PopBackStack : EnvelopeFilterSideEffect diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt index 134a6d07..8272381f 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterScreen.kt @@ -9,11 +9,13 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.Preview @@ -30,10 +32,16 @@ import com.susu.core.designsystem.component.button.SusuFilledButton import com.susu.core.designsystem.component.button.SusuLinedButton import com.susu.core.designsystem.component.button.XSmallButtonStyle import com.susu.core.designsystem.theme.SusuTheme +import com.susu.core.model.Friend import com.susu.core.ui.extension.collectWithLifecycle +import com.susu.core.ui.extension.toMoneyFormat import com.susu.feature.envelopefilter.component.MoneySlider +import com.susu.feature.envelopefilter.component.SearchBar import com.susu.feature.sent.R +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.flow.debounce +@OptIn(FlowPreview::class) @Composable fun EnvelopeFilterRoute( viewModel: EnvelopeFilterViewModel = hiltViewModel(), @@ -54,21 +62,35 @@ fun EnvelopeFilterRoute( viewModel.initData() } + LaunchedEffect(key1 = uiState.searchKeyword) { + snapshotFlow { uiState.searchKeyword } + .debounce(100L) + .collect(viewModel::getFriendList) + } + EnvelopeFilterScreen( uiState = uiState, onClickBackIcon = viewModel::popBackStack, onClickApplyFilterButton = viewModel::popBackStackWithFilter, + onTextChangeSearch = viewModel::updateName, + onClickFriendChip = viewModel::selectFriend, + onCloseFriendChip = viewModel::unselectFriend, + onMoneyValueChange = viewModel::updateMoneyRange, + onClickRefreshButton = viewModel::clearFilter, ) } @OptIn(ExperimentalLayoutApi::class) @Composable fun EnvelopeFilterScreen( - @Suppress("detekt:UnusedParameter") uiState: EnvelopeFilterState = EnvelopeFilterState(), onClickBackIcon: () -> Unit = {}, onClickApplyFilterButton: () -> Unit = {}, onClickRefreshButton: () -> Unit = {}, + onTextChangeSearch: (String) -> Unit = {}, + onClickFriendChip: (Friend) -> Unit = {}, + onCloseFriendChip: (Friend) -> Unit = {}, + onMoneyValueChange: (Float?, Float?) -> Unit = { _, _ -> }, ) { Column( modifier = Modifier @@ -92,50 +114,77 @@ fun EnvelopeFilterScreen( ) { Text(text = stringResource(R.string.envelope_filter_screen_friend), style = SusuTheme.typography.title_xs) Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) + SearchBar( + value = uiState.searchKeyword, + placeholder = stringResource(R.string.envelope_filter_search_placeholder), + onValueChange = onTextChangeSearch, + ) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) FlowRow( horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), ) { - listOf("이진욱", "김철수", "홍길동", "박예은", "박미영", "서한누리", "서한누리").forEach { category -> + uiState.friendList.forEach { friend -> SusuLinedButton( color = LinedButtonColor.Black, style = XSmallButtonStyle.height28, - isActive = true, - text = category, - onClick = { }, + isActive = friend in uiState.selectedFriendList, + text = friend.name, + onClick = { onClickFriendChip(friend) }, ) } } Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxxxxxl)) - Text( - text = stringResource(R.string.envelope_filter_screen_money), - style = SusuTheme.typography.title_xs, - ) - Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) - Text(text = "20,000원~100,000원", style = SusuTheme.typography.title_m) - - Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) - - MoneySlider(value = 20_000f..100_000f, onValueChange = {}, valueRange = 0f..100_000f) + if (uiState.maxFromAmount != uiState.maxToAmount) { + Text( + text = stringResource(R.string.envelope_filter_screen_money), + style = SusuTheme.typography.title_xs, + ) + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_m)) + + Text( + text = stringResource( + R.string.envelope_filter_range_text, + uiState.sliderValue.start.toMoneyFormat(), + uiState.sliderValue.endInclusive.toMoneyFormat(), + ), + style = SusuTheme.typography.title_m, + ) + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxs)) + + MoneySlider( + value = uiState.sliderValue, + onValueChange = { onMoneyValueChange(it.start, it.endInclusive) }, + valueRange = uiState.sliderValueRange, + ) + } Spacer(modifier = Modifier.weight(1f)) Column( + modifier = Modifier.imePadding(), verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_m), ) { FlowRow( verticalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), horizontalArrangement = Arrangement.spacedBy(SusuTheme.spacing.spacing_xxs), ) { - SelectedFilterButton( - name = "이진욱", - ) - - SelectedFilterButton( - name = "20,000~10,000", - ) + uiState.selectedFriendList.forEach { friend -> + SelectedFilterButton( + name = friend.name, + onClickCloseIcon = { onCloseFriendChip(friend) }, + ) + } + + if (uiState.fromAmount != null) { + SelectedFilterButton( + name = "${uiState.sliderValue.start.toMoneyFormat()}~${uiState.sliderValue.endInclusive.toMoneyFormat()}", + onClickCloseIcon = { onMoneyValueChange(null, null) }, + ) + } } Row( diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt index b16f4b49..78999fcd 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/EnvelopeFilterViewModel.kt @@ -1,43 +1,126 @@ package com.susu.feature.envelopefilter import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope +import com.susu.core.model.Friend +import com.susu.core.ui.argument.EnvelopeFilterArgument import com.susu.core.ui.base.BaseViewModel +import com.susu.core.ui.extension.decodeFromUri +import com.susu.core.ui.extension.encodeToUri +import com.susu.domain.usecase.envelope.GetEnvelopeFilterConfigUseCase +import com.susu.domain.usecase.friend.SearchFriendUseCase +import com.susu.feature.envelopefilter.component.roundToStep +import com.susu.feature.sent.navigation.SentRoute import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toPersistentList +import kotlinx.coroutines.launch +import kotlinx.serialization.json.Json import javax.inject.Inject +import kotlin.math.roundToInt @HiltViewModel class EnvelopeFilterViewModel @Inject constructor( - @Suppress("detekt:UnusedPrivateProperty") + private val searchFriendUseCase: SearchFriendUseCase, + private val getEnvelopeFilterConfigUseCase: GetEnvelopeFilterConfigUseCase, savedStateHandle: SavedStateHandle, ) : BaseViewModel( EnvelopeFilterState(), ) { -// private val argument = savedStateHandle.get(ReceivedRoute.FILTER_ARGUMENT_NAME)!! -// private var filter = FilterArgument() + private val argument = savedStateHandle.get(SentRoute.FILTER_ENVELOPE_ARGUMENT)!! + private var filter = EnvelopeFilterArgument() + private var step = 0f - fun initData() { - initFilter() + fun initData() = viewModelScope.launch { + filter = Json.decodeFromUri(argument) + getEnvelopeFilterConfigUseCase() + .onSuccess { + val maxFromAmount = if (filter.isSent) it.minSentAmount else it.minReceivedAmount + val maxToAmount = if (filter.isSent) it.maxSentAmount else it.maxReceivedAmount + + step = when { + maxToAmount <= 10_00 -> 1f + maxToAmount <= 10_000 -> 1000f + maxToAmount <= 1_000_000 -> 10_000f // 0원 ~ 100만원 범위, 1만원 간격 + maxToAmount <= 5_000_000 -> 50_000f // 101만원 ~ 500만원 범위, 5만원 간격 + else -> 10_0000f // 500만원 이상, 10만원 간격 + } + + intent { + copy( + selectedFriendList = filter.selectedFriendList.toPersistentList(), + maxFromAmount = maxFromAmount, + maxToAmount = maxToAmount, + fromAmount = filter.fromAmount, + toAmount = filter.toAmount, + ) + } + } + } + + fun updateName(searchKeyword: String) = intent { + copy( + searchKeyword = searchKeyword, + ) + } + + fun getFriendList(search: String) = viewModelScope.launch { + searchFriendUseCase(name = search) + .onSuccess { + intent { + copy( + friendList = it.map { it.friend }.toPersistentList(), + ) + } + } + } + + fun selectFriend(friend: Friend) = intent { + if (friend in selectedFriendList) return@intent this + copy( + selectedFriendList = selectedFriendList.add(friend), + ) } - private fun initFilter() { -// filter = Json.decodeFromUri(argument) -// intent { -// copy( -// selectedCategoryList = filter.selectedCategoryList.toPersistentList(), -// startAt = filter.startAt?.toJavaLocalDateTime(), -// endAt = filter.endAt?.toJavaLocalDateTime(), -// ) -// } + fun unselectFriend(friend: Friend) = intent { + if (friend !in selectedFriendList) return@intent this + copy( + selectedFriendList = selectedFriendList.remove(friend), + ) } fun popBackStack() = postSideEffect(EnvelopeFilterSideEffect.PopBackStack) fun popBackStackWithFilter() { -// val filter = FilterArgument( -// selectedCategoryList = currentState.selectedCategoryList, -// startAt = currentState.startAt?.toKotlinLocalDateTime(), -// endAt = currentState.endAt?.toKotlinLocalDateTime(), -// ) -// -// postSideEffect(EnvelopeFilterSideEffect.PopBackStackWithFilter(Json.encodeToUri(filter))) + val filter = EnvelopeFilterArgument( + selectedFriendList = currentState.selectedFriendList, + fromAmount = currentState.fromAmount, + toAmount = currentState.toAmount, + ) + + postSideEffect(EnvelopeFilterSideEffect.PopBackStackWithFilter(Json.encodeToUri(filter))) + } + + fun updateMoneyRange(fromAmount: Float?, toAmount: Float?) = intent { + copy( + toAmount = toAmount?.roundToStep(step, maxFromAmount, maxToAmount)?.toLong(), + fromAmount = fromAmount?.roundToStep(step, maxFromAmount, maxToAmount)?.toLong(), + ) + } + + private fun Float.roundToStep(step: Float, min: Long, max: Long): Float { + val value = (this / step).roundToInt() * step + return when { + value < min -> min.toFloat() + value > max -> max.toFloat() + else -> value + } + } + + fun clearFilter() = intent { + copy( + selectedFriendList = persistentListOf(), + fromAmount = null, + toAmount = null, + ) } } diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt index ea36a7e0..b963cec9 100644 --- a/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/MoneySlider.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp import com.susu.core.designsystem.theme.Gray10 +import com.susu.core.designsystem.theme.Gray60 import com.susu.core.designsystem.theme.Orange20 import com.susu.core.designsystem.theme.Orange60 import com.susu.core.designsystem.theme.SusuTheme @@ -86,7 +87,13 @@ fun MoneySlider( private fun MoneySliderThumb() { Box( modifier = Modifier - .shadow(elevation = 8.dp, spotColor = Color(0x14000000), ambientColor = Color(0x14000000), clip = true, shape = CircleShape) + .shadow( + elevation = 8.dp, + spotColor = Gray60, + ambientColor = Gray60, + clip = true, + shape = CircleShape, + ) .size(24.dp) .background(color = Gray10, shape = CircleShape) .padding(SusuTheme.spacing.spacing_xxxs), diff --git a/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/SearchBar.kt b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/SearchBar.kt new file mode 100644 index 00000000..79e013b6 --- /dev/null +++ b/feature/sent/src/main/java/com/susu/feature/envelopefilter/component/SearchBar.kt @@ -0,0 +1,126 @@ +package com.susu.feature.envelopefilter.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import com.susu.core.designsystem.R +import com.susu.core.designsystem.component.button.ClearIconButton +import com.susu.core.designsystem.theme.Gray100 +import com.susu.core.designsystem.theme.Gray20 +import com.susu.core.designsystem.theme.Gray60 +import com.susu.core.designsystem.theme.SusuTheme + +@Composable +fun SearchBar( + modifier: Modifier = Modifier, + value: String, + onValueChange: (String) -> Unit = {}, + onClickClearIcon: () -> Unit = {}, + maxLines: Int = 1, + minLines: Int = 1, + textColor: Color = Gray100, + textStyle: TextStyle = SusuTheme.typography.text_xxs, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + placeholder: String, + placeholderColor: Color = Gray60, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActions = KeyboardActions.Default, + cursorBrush: Brush = SolidColor(Color.Black), +) { + BasicTextField( + value = value, + onValueChange = onValueChange, + modifier = modifier, + textStyle = textStyle.copy(color = textColor), + singleLine = maxLines == 1, + maxLines = if (minLines > maxLines) minLines else maxLines, + minLines = minLines, + interactionSource = interactionSource, + cursorBrush = cursorBrush, + keyboardOptions = keyboardOptions, + keyboardActions = keyboardActions, + decorationBox = { innerText -> + Row( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(Gray20) + .padding( + horizontal = SusuTheme.spacing.spacing_s, + vertical = SusuTheme.spacing.spacing_xxxs, + ), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + modifier = Modifier.size(20.dp), + painter = painterResource(id = R.drawable.ic_search), + contentDescription = null, + tint = Gray60, + ) + + Spacer(modifier = Modifier.size(SusuTheme.spacing.spacing_xxxxs)) + + Box( + modifier = Modifier.weight(1f), + contentAlignment = Alignment.CenterStart, + ) { + innerText() + if (value.isEmpty()) { + Text( + text = placeholder, + color = placeholderColor, + style = textStyle, + ) + } + } + + if (value.isNotEmpty()) { + ClearIconButton( + iconSize = 20.dp, + onClick = onClickClearIcon, + ) + } + } + }, + ) +} + +@Preview +@Composable +fun SearchBarPreview() { + SusuTheme { + var text by remember { + mutableStateOf("보낸 사람 검색") + } + + SearchBar( + value = text, + onValueChange = { text = it }, + placeholder = "찾고 싶은 장부를 검색해보세요", + ) + } +} diff --git a/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt b/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt index 77ce1bf0..df35820a 100644 --- a/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt +++ b/feature/sent/src/main/java/com/susu/feature/sent/navigation/SentNavigation.kt @@ -15,6 +15,7 @@ import com.susu.feature.envelope.SentEnvelopeRoute import com.susu.feature.envelopeadd.SentEnvelopeAddRoute import com.susu.feature.envelopedetail.SentEnvelopeDetailRoute import com.susu.feature.envelopeedit.SentEnvelopeEditRoute +import com.susu.feature.envelopefilter.EnvelopeFilterRoute import com.susu.feature.envelopesearch.SentEnvelopeSearchRoute import com.susu.feature.sent.SentRoute import kotlinx.serialization.json.Json @@ -43,6 +44,10 @@ fun NavController.navigateSentEnvelopeSearch() { navigate(SentRoute.sentEnvelopeSearchRoute) } +fun NavController.navigateEnvelopeFilter(filter: String) { + navigate(SentRoute.envelopeFilterRoute(filter)) +} + fun NavGraphBuilder.sentNavGraph( padding: PaddingValues, popBackStack: () -> Unit, @@ -53,6 +58,7 @@ fun NavGraphBuilder.sentNavGraph( navigateSentEnvelopeSearch: () -> Unit, onShowSnackbar: (SnackbarToken) -> Unit, onShowDialog: (DialogToken) -> Unit, + popBackStackWithFilter: (String) -> Unit, handleException: (Throwable, () -> Unit) -> Unit, ) { composable(route = SentRoute.route) { @@ -114,6 +120,16 @@ fun NavGraphBuilder.sentNavGraph( popBackStack = popBackStack, ) } + + composable( + route = SentRoute.envelopeFilterRoute("{${SentRoute.FILTER_ENVELOPE_ARGUMENT}}"), + ) { + EnvelopeFilterRoute( + popBackStack = popBackStack, + popBackStackWithFilter = popBackStackWithFilter, + handleException = handleException, + ) + } } object SentRoute { @@ -129,4 +145,7 @@ object SentRoute { fun sentEnvelopeEditRoute(envelopeDetail: String) = "sent-envelope-edit/$envelopeDetail" const val ENVELOPE_DETAIL_ARGUMENT_NAME = "envelope-detail" const val sentEnvelopeSearchRoute = "sent-envelope-search" + const val FILTER_ENVELOPE_ARGUMENT = "filter-envelope" + + fun envelopeFilterRoute(filter: String) = "envelope-filter/$filter" } diff --git a/feature/sent/src/main/res/values/strings.xml b/feature/sent/src/main/res/values/strings.xml index 9208b6cc..f2ecea71 100644 --- a/feature/sent/src/main/res/values/strings.xml +++ b/feature/sent/src/main/res/values/strings.xml @@ -44,6 +44,8 @@ 원하는 검색 결과가 없나요? 보낸 사람 받은 봉투 금액 + 보낸 사람을 검색해보세요 + %1$s원~%2$s원 님에게 "님의 " "을 "