diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2784c6a..09ff1086 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -10,8 +10,8 @@ android { defaultConfig { applicationId = "com.withpeace.withpeace" targetSdk = 34 - versionCode = 11 - versionName = "2.1.1" + versionCode = 14 + versionName = "2.2.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { @@ -58,6 +58,7 @@ dependencies { implementation(project(":feature:policydetail")) implementation(project(":feature:policybookmarks")) implementation(project(":feature:disablepolicy")) + implementation(project(":feature:policylist")) implementation(project(":core:ui")) implementation(project(":core:interceptor")) implementation(project(":core:data")) diff --git a/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt b/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt index d5199f6e..db86cad1 100644 --- a/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt +++ b/app/src/main/java/com/withpeace/withpeace/MainBottomNavigation.kt @@ -1,6 +1,5 @@ package com.withpeace.withpeace -import android.util.Log import androidx.annotation.StringRes import androidx.compose.foundation.Image import androidx.compose.material3.NavigationBar @@ -19,10 +18,11 @@ import androidx.navigation.navOptions import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme import com.withpeace.withpeace.feature.home.navigation.HOME_ROUTE import com.withpeace.withpeace.feature.home.navigation.navigateHome +import com.withpeace.withpeace.feature.policylist.navigation.POLICY_LIST_ROUTE +import com.withpeace.withpeace.feature.policylist.navigation.navigateToPolicyList import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE +import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE_WITH_ARGUMENT import com.withpeace.withpeace.feature.postlist.navigation.navigateToPostList -import com.withpeace.withpeace.feature.registerpost.navigation.REGISTER_POST_ROUTE -import com.withpeace.withpeace.feature.registerpost.navigation.navigateToRegisterPost import com.withpeace.withpeace.navigation.MY_PAGE_NESTED_ROUTE @Composable @@ -79,8 +79,8 @@ private fun NavController.navigateToTabScreen(bottomTab: BottomTab) { when (bottomTab) { BottomTab.HOME -> navigateHome(tabNavOptions) - BottomTab.POST -> navigateToPostList(tabNavOptions) - BottomTab.REGISTER_POST -> navigateToRegisterPost() + BottomTab.POLICY -> navigateToPolicyList(tabNavOptions) + BottomTab.POST -> navigateToPostList(navOptions = tabNavOptions) BottomTab.MY_PAGE -> navigate(MY_PAGE_NESTED_ROUTE, tabNavOptions) } } @@ -97,17 +97,17 @@ enum class BottomTab( contentDescription = R.string.home, HOME_ROUTE, ), + POLICY( + iconUnSelectedResId = R.drawable.ic_bottom_policy, + iconSelectedResId = R.drawable.ic_bottom_policy_select, + contentDescription = R.string.youth_policy, + POLICY_LIST_ROUTE, + ), POST( - iconUnSelectedResId = R.drawable.ic_bottom_post, - iconSelectedResId = R.drawable.ic_bottom_post_select, + iconUnSelectedResId = R.drawable.ic_bottom_community, + iconSelectedResId = R.drawable.ic_bottom_community_select, contentDescription = R.string.post, - POST_LIST_ROUTE, - ), - REGISTER_POST( - iconUnSelectedResId = R.drawable.ic_upload, - iconSelectedResId = R.drawable.ic_upload, - contentDescription = R.string.register, - REGISTER_POST_ROUTE, + POST_LIST_ROUTE_WITH_ARGUMENT, ), MY_PAGE( iconUnSelectedResId = R.drawable.ic_bottom_my_page, @@ -119,7 +119,6 @@ enum class BottomTab( companion object { operator fun contains(route: String): Boolean { - if (route == REGISTER_POST_ROUTE) return false return entries.map { it.route }.contains(route) } } diff --git a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt index 90d280e1..350b5761 100644 --- a/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt +++ b/app/src/main/java/com/withpeace/withpeace/navigation/NavHost.kt @@ -5,6 +5,7 @@ import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost import androidx.navigation.navOptions @@ -13,12 +14,10 @@ import com.app.profileeditor.navigation.navigateProfileEditor import com.app.profileeditor.navigation.profileEditorNavGraph import com.withpeace.withpeace.core.designsystem.ui.snackbar.SnackbarState import com.withpeace.withpeace.core.designsystem.ui.snackbar.SnackbarType -import com.withpeace.withpeace.feature.disablepolicy.navigation.DISABLE_POLICY_ID_ARGUMENT import com.withpeace.withpeace.feature.disablepolicy.navigation.disabledPolicyNavGraph import com.withpeace.withpeace.feature.disablepolicy.navigation.navigateDisabledPolicy import com.withpeace.withpeace.feature.gallery.navigation.galleryNavGraph import com.withpeace.withpeace.feature.gallery.navigation.navigateToGallery -import com.withpeace.withpeace.feature.home.navigation.HOME_ROUTE import com.withpeace.withpeace.feature.home.navigation.homeNavGraph import com.withpeace.withpeace.feature.home.navigation.navigateHome import com.withpeace.withpeace.feature.login.navigation.LOGIN_ROUTE @@ -32,11 +31,13 @@ import com.withpeace.withpeace.feature.policyconsent.navigation.navigateToPolicy import com.withpeace.withpeace.feature.policyconsent.navigation.policyConsentGraph import com.withpeace.withpeace.feature.policydetail.navigation.navigateToPolicyDetail import com.withpeace.withpeace.feature.policydetail.navigation.policyDetailNavGraph +import com.withpeace.withpeace.feature.policylist.navigation.policyListGraph import com.withpeace.withpeace.feature.postdetail.navigation.POST_DETAIL_ROUTE_WITH_ARGUMENT import com.withpeace.withpeace.feature.postdetail.navigation.navigateToPostDetail import com.withpeace.withpeace.feature.postdetail.navigation.postDetailGraph import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_DELETED_POST_ID_ARGUMENT import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_ROUTE +import com.withpeace.withpeace.feature.postlist.navigation.navigateToPostList import com.withpeace.withpeace.feature.postlist.navigation.postListGraph import com.withpeace.withpeace.feature.privacypolicy.navigation.navigateToPrivacyPolicy import com.withpeace.withpeace.feature.privacypolicy.navigation.privacyPolicyGraph @@ -191,7 +192,21 @@ fun WithpeaceNavHost( ) }, onPolicyClick = { - navController.navigateToPolicyDetail(policyId = it) + navController.navigateToPolicyDetail( + policyId = it, + ) + }, + onPostClick = { // TODO 인스턴스가 존재할 때, argument 로딩안됨 + navController.navigateToPostList( + it.name, + navOptions = navOptions { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + }, + ) }, ) policyDetailNavGraph( @@ -319,6 +334,28 @@ fun WithpeaceNavHost( onAuthExpired = { onAuthExpired(onShowSnackBar, navController) }, + onClickRegisterPost = { + navController.navigateToRegisterPost() + } + ) + policyListGraph( + onShowSnackBar = { onShowSnackBar(SnackbarState(it)) }, + onNavigationSnackBar = { + onShowSnackBar( + SnackbarState( + it, + SnackbarType.Navigator( + actionName = "목록 보러가기", + action = { + navController.navigatePolicyBookmarks() + }, + ), + ), + ) + }, + onPolicyClick = { + navController.navigateToPolicyDetail(policyId = it) + }, ) } } diff --git a/app/src/main/res/drawable/ic_bottom_community.xml b/app/src/main/res/drawable/ic_bottom_community.xml new file mode 100644 index 00000000..ba5734e9 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_community.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_community_select.xml b/app/src/main/res/drawable/ic_bottom_community_select.xml new file mode 100644 index 00000000..259e6cd6 --- /dev/null +++ b/app/src/main/res/drawable/ic_bottom_community_select.xml @@ -0,0 +1,16 @@ + + + + diff --git a/app/src/main/res/drawable/ic_bottom_post.xml b/app/src/main/res/drawable/ic_bottom_policy.xml similarity index 100% rename from app/src/main/res/drawable/ic_bottom_post.xml rename to app/src/main/res/drawable/ic_bottom_policy.xml diff --git a/app/src/main/res/drawable/ic_bottom_post_select.xml b/app/src/main/res/drawable/ic_bottom_policy_select.xml similarity index 100% rename from app/src/main/res/drawable/ic_bottom_post_select.xml rename to app/src/main/res/drawable/ic_bottom_policy_select.xml diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 85131293..dca505e1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,7 +1,7 @@ 청하 - 게시판 + 청년정책 + 커뮤니티 마이페이지 - 등록 diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RecentPostMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RecentPostMapper.kt new file mode 100644 index 00000000..1919e8bb --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/RecentPostMapper.kt @@ -0,0 +1,10 @@ +package com.withpeace.withpeace.core.data.mapper + +import com.withpeace.withpeace.core.domain.model.post.RecentPost +import com.withpeace.withpeace.core.network.di.response.post.RecentPostResponse + +fun RecentPostResponse.toDomain(): RecentPost { + return RecentPost( + id = postId, title = title, type = type.toDomain(), + ) +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/youthpolicy/PolicyFilterMapper.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/youthpolicy/PolicyFilterMapper.kt new file mode 100644 index 00000000..235582dd --- /dev/null +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/mapper/youthpolicy/PolicyFilterMapper.kt @@ -0,0 +1,17 @@ +package com.withpeace.withpeace.core.data.mapper.youthpolicy + +import com.withpeace.withpeace.core.domain.model.policy.PolicyClassification +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.domain.model.policy.PolicyRegion +import com.withpeace.withpeace.core.network.di.response.policy.UserPolicyFilterResponse + +fun UserPolicyFilterResponse.toDomain(): PolicyFilters { + return PolicyFilters( + regions = region.map { name -> + PolicyRegion.entries.find { it.name == name } ?: PolicyRegion.기타 + }, + classifications = classification.map { name -> + PolicyClassification.entries.find { it.name == name } ?: PolicyClassification.ETC + }, + ) +} \ No newline at end of file diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt index 0575eae0..eec8f336 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultPostRepository.kt @@ -18,6 +18,7 @@ import com.withpeace.withpeace.core.domain.model.error.ResponseError import com.withpeace.withpeace.core.domain.model.post.Post import com.withpeace.withpeace.core.domain.model.post.PostDetail import com.withpeace.withpeace.core.domain.model.post.PostTopic +import com.withpeace.withpeace.core.domain.model.post.RecentPost import com.withpeace.withpeace.core.domain.model.post.RegisterPost import com.withpeace.withpeace.core.domain.model.post.ReportType import com.withpeace.withpeace.core.domain.repository.PostRepository @@ -147,6 +148,13 @@ class DefaultPostRepository @Inject constructor( } } + override fun getRecentPost(onError: suspend (CheonghaError) -> Unit): Flow> = + flow { + postService.getRecentPost().suspendMapSuccess { + emit(this.data.map { it.toDomain() }) + }.handleApiFailure(onError) + } + private fun getImageRequestBodies( imageUris: List, ): List { diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt index 0d946c56..4a8b6664 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultUserRepository.kt @@ -7,6 +7,8 @@ import com.withpeace.withpeace.core.analytics.AnalyticsEvent import com.withpeace.withpeace.core.analytics.AnalyticsHelper import com.withpeace.withpeace.core.data.analytics.event import com.withpeace.withpeace.core.data.mapper.toDomain +import com.withpeace.withpeace.core.data.mapper.youthpolicy.toCode +import com.withpeace.withpeace.core.data.mapper.youthpolicy.toDomain import com.withpeace.withpeace.core.data.util.convertToFile import com.withpeace.withpeace.core.data.util.handleApiFailure import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource @@ -15,6 +17,7 @@ import com.withpeace.withpeace.core.domain.model.SignUpInfo import com.withpeace.withpeace.core.domain.model.error.CheonghaError import com.withpeace.withpeace.core.domain.model.error.ClientError import com.withpeace.withpeace.core.domain.model.error.ResponseError +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile import com.withpeace.withpeace.core.domain.model.profile.Nickname import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo @@ -133,6 +136,25 @@ class DefaultUserRepository @Inject constructor( } } + override fun updatePolicyFilter( + policyFilters: PolicyFilters, + onError: suspend (CheonghaError) -> Unit, + ): Flow = flow { + userService.patchPolicyFilter( + region = policyFilters.regions.joinToString(",") { it.toCode() }, + classification = policyFilters.classifications.joinToString(",") { it.toCode() }, + ).suspendMapSuccess { + emit(Unit) + }.handleApiFailure(onError) + } + + override fun getPolicyFilter(onError: suspend (CheonghaError) -> Unit): Flow = + flow { + userService.getPolicyFilter().suspendMapSuccess { + emit(this.data.toDomain()) + }.handleApiFailure(onError) + } + override fun withdraw(onError: suspend (CheonghaError) -> Unit): Flow = flow { userService.withdraw().suspendMapSuccess { diff --git a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt index d99b29c3..90ebdcdd 100644 --- a/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt +++ b/core/data/src/main/kotlin/com/withpeace/withpeace/core/data/repository/DefaultYouthPolicyRepository.kt @@ -84,6 +84,24 @@ class DefaultYouthPolicyRepository @Inject constructor( } } + override fun getRecommendPolicy(onError: suspend (CheonghaError) -> Unit): Flow> = + flow { + youthPolicyService.getRecommendations().suspendMapSuccess { + emit(data.map { it.toDomain() }) + }.handleApiFailure { + onErrorWithAuthExpired(it, onError) + } + } + + override fun getHotPolicy(onError: suspend (CheonghaError) -> Unit): Flow> = + flow { + youthPolicyService.getHots().suspendMapSuccess { + emit(data.map { it.toDomain() }) + }.handleApiFailure { + onErrorWithAuthExpired(it, onError) + } + } + private suspend fun onErrorWithAuthExpired( it: ResponseError, onError: suspend (CheonghaError) -> Unit, diff --git a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt index ed063d26..847e1ef5 100644 --- a/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt +++ b/core/designsystem/src/main/java/com/withpeace/withpeace/core/designsystem/theme/Color.kt @@ -26,6 +26,8 @@ val systemGray4 = Color(0xFF696969) val systemError = Color(0xFFF0474B) val systemSuccess = Color(0xFF3BD569) val systemHyperLink = Color(0xFF20BCBB) +val gray3_70 = Color(0xB2ECECEF) +val toolTipBackground = Color(0xFFF9FBFB) val snackbarBlack = Color(0xFF000000) @@ -46,6 +48,8 @@ data class WithPeaceColor( val SystemSuccess: Color = systemSuccess, val SystemHyperLink: Color = systemHyperLink, val SnackbarBlack: Color = snackbarBlack, + val Gray3_70: Color = gray3_70, + val ToolTipBackground: Color = toolTipBackground, ) val lightColor = WithPeaceColor() diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RecentPost.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RecentPost.kt new file mode 100644 index 00000000..e22513f1 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/model/post/RecentPost.kt @@ -0,0 +1,7 @@ +package com.withpeace.withpeace.core.domain.model.post + +data class RecentPost( + val id: Long, + val title: String, + val type: PostTopic, +) diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt index 7221785c..65888846 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/PostRepository.kt @@ -5,6 +5,7 @@ import com.withpeace.withpeace.core.domain.model.error.CheonghaError import com.withpeace.withpeace.core.domain.model.post.Post import com.withpeace.withpeace.core.domain.model.post.PostDetail import com.withpeace.withpeace.core.domain.model.post.PostTopic +import com.withpeace.withpeace.core.domain.model.post.RecentPost import com.withpeace.withpeace.core.domain.model.post.RegisterPost import com.withpeace.withpeace.core.domain.model.post.ReportType import kotlinx.coroutines.flow.Flow @@ -48,4 +49,8 @@ interface PostRepository { reportType: ReportType, onError: suspend (CheonghaError) -> Unit, ): Flow + + fun getRecentPost( + onError: suspend (CheonghaError) -> Unit, + ): Flow> } diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt index 65f1cdbc..8fb08545 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/UserRepository.kt @@ -1,8 +1,8 @@ package com.withpeace.withpeace.core.domain.repository -import com.withpeace.withpeace.core.domain.model.error.ResponseError import com.withpeace.withpeace.core.domain.model.SignUpInfo import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters import com.withpeace.withpeace.core.domain.model.profile.ChangedProfile import com.withpeace.withpeace.core.domain.model.profile.Nickname import com.withpeace.withpeace.core.domain.model.profile.ProfileInfo @@ -42,4 +42,11 @@ interface UserRepository { fun withdraw( onError: suspend (CheonghaError) -> Unit, ): Flow + + fun updatePolicyFilter( + policyFilters: PolicyFilters, + onError: suspend (CheonghaError) -> Unit, + ): Flow + + fun getPolicyFilter(onError: suspend (CheonghaError) -> Unit): Flow } diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt index 3792666d..b057e585 100644 --- a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/repository/YouthPolicyRepository.kt @@ -32,4 +32,12 @@ interface YouthPolicyRepository { policyId: String, onError: suspend (CheonghaError) -> Unit, ): Flow + + fun getRecommendPolicy( + onError: suspend (CheonghaError) -> Unit, + ): Flow> + + fun getHotPolicy( + onError: suspend (CheonghaError) -> Unit, + ): Flow> } \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetHotPoliciesUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetHotPoliciesUseCase.kt new file mode 100644 index 00000000..8884b8ed --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetHotPoliciesUseCase.kt @@ -0,0 +1,12 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository +import javax.inject.Inject + +class GetHotPoliciesUseCase @Inject constructor( + private val policyRepository: YouthPolicyRepository, +) { + operator fun invoke(onError: suspend (CheonghaError) -> Unit) = + policyRepository.getHotPolicy(onError) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPolicyFilterUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPolicyFilterUseCase.kt new file mode 100644 index 00000000..2e83088b --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetPolicyFilterUseCase.kt @@ -0,0 +1,14 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.domain.repository.UserRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class GetPolicyFilterUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + operator fun invoke(onError: suspend (CheonghaError) -> Unit): Flow = + userRepository.getPolicyFilter(onError) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecentPostUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecentPostUseCase.kt new file mode 100644 index 00000000..017a815e --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecentPostUseCase.kt @@ -0,0 +1,12 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.repository.PostRepository +import javax.inject.Inject + +class GetRecentPostUseCase @Inject constructor( + private val postRepository: PostRepository, +) { + operator fun invoke(onError: suspend (CheonghaError) -> Unit) = + postRepository.getRecentPost(onError) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecommendPoliciesUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecommendPoliciesUseCase.kt new file mode 100644 index 00000000..6d1ed602 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/GetRecommendPoliciesUseCase.kt @@ -0,0 +1,12 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.repository.YouthPolicyRepository +import javax.inject.Inject + +class GetRecommendPoliciesUseCase @Inject constructor( + private val policyRepository: YouthPolicyRepository, +) { + operator fun invoke(onError: suspend (CheonghaError) -> Unit) = + policyRepository.getRecommendPolicy(onError) +} \ No newline at end of file diff --git a/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdatePolicyFilterUseCase.kt b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdatePolicyFilterUseCase.kt new file mode 100644 index 00000000..a1e6af73 --- /dev/null +++ b/core/domain/src/main/java/com/withpeace/withpeace/core/domain/usecase/UpdatePolicyFilterUseCase.kt @@ -0,0 +1,18 @@ +package com.withpeace.withpeace.core.domain.usecase + +import com.withpeace.withpeace.core.domain.model.error.CheonghaError +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.domain.repository.UserRepository +import kotlinx.coroutines.flow.Flow +import javax.inject.Inject + +class UpdatePolicyFilterUseCase @Inject constructor( + private val userRepository: UserRepository, +) { + operator fun invoke( + policyFilters: PolicyFilters, + onError: (CheonghaError) -> Unit, + ): Flow { + return userRepository.updatePolicyFilter(policyFilters = policyFilters, onError = onError) + } +} \ No newline at end of file diff --git a/core/network/build.gradle.kts b/core/network/build.gradle.kts index 09dc670f..f865698b 100644 --- a/core/network/build.gradle.kts +++ b/core/network/build.gradle.kts @@ -31,8 +31,5 @@ dependencies { implementation(libs.retrofit.core) implementation(libs.okhttp.logging) implementation(libs.skydoves.sandwich) - kapt(libs.tikxml.processor) - implementation(libs.tikxml.core) - implementation(libs.retrofit.tikxml.converter) - implementation(libs.tikxml.annotation) + implementation(project(":core:datastore")) } diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/AuthInterceptor.kt similarity index 98% rename from core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt rename to core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/AuthInterceptor.kt index 0b2fdba1..325157e8 100644 --- a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/AuthInterceptor.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/common/AuthInterceptor.kt @@ -1,4 +1,4 @@ -package com.withpeace.withpeace.core.interceptor +package com.withpeace.withpeace.core.network.di.common import com.skydoves.sandwich.suspendMapSuccess import com.skydoves.sandwich.suspendOnError diff --git a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/InterceptorModule.kt similarity index 84% rename from core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt rename to core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/InterceptorModule.kt index b75a642a..d661b6bd 100644 --- a/core/interceptor/src/main/java/com/withpeace/withpeace/core/interceptor/InterceptorModule.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/InterceptorModule.kt @@ -1,6 +1,7 @@ -package com.withpeace.withpeace.core.interceptor +package com.withpeace.withpeace.core.network.di.di import com.withpeace.withpeace.core.datastore.dataStore.token.TokenPreferenceDataSource +import com.withpeace.withpeace.core.network.di.common.AuthInterceptor import com.withpeace.withpeace.core.network.di.service.AuthService import dagger.Module import dagger.Provides diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt index 7b7f1f39..d6e62489 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/di/NetworkModule.kt @@ -2,8 +2,6 @@ package com.withpeace.withpeace.core.network.di.di import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import com.skydoves.sandwich.adapters.ApiResponseCallAdapterFactory -import com.tickaroo.tikxml.TikXml -import com.tickaroo.tikxml.retrofit.TikXmlConverterFactory import com.withpeace.withpeace.core.network.BuildConfig import dagger.Module import dagger.Provides diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/UserPolicyFilterResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/UserPolicyFilterResponse.kt new file mode 100644 index 00000000..fa7723f5 --- /dev/null +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/policy/UserPolicyFilterResponse.kt @@ -0,0 +1,9 @@ +package com.withpeace.withpeace.core.network.di.response.policy + +import kotlinx.serialization.Serializable + +@Serializable +data class UserPolicyFilterResponse( + val classification: List, + val region: List, +) diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/RecentPostResponse.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/RecentPostResponse.kt new file mode 100644 index 00000000..e672a1a0 --- /dev/null +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/response/post/RecentPostResponse.kt @@ -0,0 +1,10 @@ +package com.withpeace.withpeace.core.network.di.response.post + +import kotlinx.serialization.Serializable + +@Serializable +data class RecentPostResponse( + val type: PostTopicResponse, + val postId: Long, + val title: String, +) diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PolicyService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PolicyService.kt deleted file mode 100644 index d499a4c9..00000000 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PolicyService.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.withpeace.withpeace.core.network.di.service - -import com.skydoves.sandwich.ApiResponse -import com.withpeace.withpeace.core.network.di.response.BaseResponse -import com.withpeace.withpeace.core.network.di.response.policy.BookmarkedPolicyResponse -import com.withpeace.withpeace.core.network.di.response.policy.PolicyDetailResponse -import com.withpeace.withpeace.core.network.di.response.policy.PolicyResponse -import retrofit2.http.DELETE -import retrofit2.http.GET -import retrofit2.http.POST -import retrofit2.http.Path -import retrofit2.http.Query - -interface PolicyService { - @GET("/api/v1/policies") - suspend fun getPolicies( - @Query("region") region: String, - @Query("classification") classification: String, - @Query("pageIndex") pageIndex: Int, - @Query("display") display: Int, - ): ApiResponse>> - - @GET("/api/v1/policies/{policyId}") - suspend fun getPolicyDetail(@Path("policyId") policyId: String): ApiResponse> - - @POST("/api/v1/policies/{policyId}/favorites") - suspend fun bookmarkPolicy(@Path("policyId") policyId: String): ApiResponse> - - @DELETE("/api/v1/policies/{policyId}/favorites") - suspend fun unBookmarkPolicy(@Path("policyId") policyId: String): ApiResponse> - - @GET("/api/v1/policies/favorites") - suspend fun getBookmarkPolicies(): ApiResponse>> -} \ No newline at end of file diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt index b910ee02..ba2e9aff 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/PostService.kt @@ -7,6 +7,7 @@ import com.withpeace.withpeace.core.network.di.response.BaseResponse import com.withpeace.withpeace.core.network.di.response.post.PostDetailResponse import com.withpeace.withpeace.core.network.di.response.post.PostIdResponse import com.withpeace.withpeace.core.network.di.response.post.PostResponse +import com.withpeace.withpeace.core.network.di.response.post.RecentPostResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.http.Body @@ -71,4 +72,7 @@ interface PostService { @Path("commentId") commentId: Long, @Body reportTypeRequest: ReportTypeRequest, ): ApiResponse> + + @GET("/api/v1/posts/recents") + suspend fun getRecentPost(): ApiResponse>> } diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt index 580d1429..b3b60b55 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/UserService.kt @@ -6,6 +6,7 @@ import com.withpeace.withpeace.core.network.di.response.BaseResponse import com.withpeace.withpeace.core.network.di.response.ChangedProfileResponse import com.withpeace.withpeace.core.network.di.response.ProfileResponse import com.withpeace.withpeace.core.network.di.response.TokenResponse +import com.withpeace.withpeace.core.network.di.response.policy.UserPolicyFilterResponse import okhttp3.MultipartBody import okhttp3.RequestBody import retrofit2.http.Body @@ -57,4 +58,13 @@ interface UserService { @DELETE("/api/v1/users") suspend fun withdraw(): ApiResponse> + + @PATCH("/api/v1/users/profile/policy-filter") + suspend fun patchPolicyFilter( + @Query("region") region: String, + @Query("classification") classification: String, + ): ApiResponse> + + @GET("/api/v1/users/profile/policy-filter") + suspend fun getPolicyFilter(): ApiResponse> } diff --git a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt index f818165b..ec46aaec 100644 --- a/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt +++ b/core/network/src/main/java/com/withpeace/withpeace/core/network/di/service/YouthPolicyService.kt @@ -31,4 +31,10 @@ interface YouthPolicyService { @GET("/api/v1/policies/favorites") suspend fun getBookmarkedPolicies(): ApiResponse>> + + @GET("/api/v1/policies/recommendations") + suspend fun getRecommendations(): ApiResponse>> + + @GET("/api/v1/policies/hot") + suspend fun getHots(): ApiResponse>> } \ No newline at end of file diff --git a/core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/FilterBottomSheet.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/FilterBottomSheet.kt new file mode 100644 index 00000000..18642f44 --- /dev/null +++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/FilterBottomSheet.kt @@ -0,0 +1,350 @@ +package com.withpeace.withpeace.core.ui.policy.filtersetting + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.Image +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.ui.R +import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel +import com.withpeace.withpeace.core.ui.policy.RegionUiModel + +@Composable +fun FilterBottomSheet( + scrollState: ScrollState, + modifier: Modifier, + selectedFilterUiState: PolicyFiltersUiModel, + onClassificationCheckChanged: (ClassificationUiModel) -> Unit, + onRegionCheckChanged: (RegionUiModel) -> Unit, + onFilterAllOff: () -> Unit, + onSearchWithFilter: () -> Unit, + onCloseFilter: () -> Unit, +) { + val filterListUiState = + remember { + mutableStateOf( + PolicyFiltersUiModel( + classifications = ClassificationUiModel.entries, + regions = RegionUiModel.entries, + ), + ) + } + val configuration = LocalConfiguration.current + val screenHeight = configuration.screenHeightDp.dp + val footerHeight = remember { mutableStateOf(0.dp) } + val localDensity = LocalDensity.current + Box( + modifier = modifier + .heightIn(0.dp, screenHeight) + .background(WithpeaceTheme.colors.SystemWhite), + ) { + FilterFooter( + modifier = modifier + .align(Alignment.BottomCenter) + .onSizeChanged { + footerHeight.value = with(localDensity) { it.height.toDp() } + }, + onFilterAllOff = onFilterAllOff, + onSearchWithFilter = onSearchWithFilter, + ) + Column( + modifier = modifier + .align(Alignment.TopCenter) + .padding(bottom = footerHeight.value), + ) { + FilterHeader( + modifier = modifier, + onCloseFilter = onCloseFilter, + ) + ScrollableFilterSection( + modifier = modifier, + filterListUiState = filterListUiState.value, + selectedFilterUiState = selectedFilterUiState, + onClassificationCheckChanged = onClassificationCheckChanged, + onRegionCheckChanged = onRegionCheckChanged, + scrollState = scrollState, + ) + } + } +} + +@Composable +private fun FilterHeader(modifier: Modifier, onCloseFilter: () -> Unit) { + Spacer(modifier = modifier.height(24.dp)) + Row(modifier = modifier.padding(horizontal = 16.dp)) { + Image( + painter = painterResource(id = R.drawable.ic_filter_close), + modifier = modifier.clickable { + onCloseFilter() + }, + contentDescription = stringResource( + R.string.filter_close, + ), + ) + Text( + text = stringResource(id = R.string.filter), + modifier = modifier.padding(start = 8.dp), + style = WithpeaceTheme.typography.title1, + color = WithpeaceTheme.colors.SystemBlack, + ) + } + Spacer(modifier = Modifier.height(16.dp)) + HorizontalDivider( + modifier = modifier + .fillMaxWidth() + .background(WithpeaceTheme.colors.SystemGray3) + .height(1.dp), + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun ScrollableFilterSection( + modifier: Modifier, + filterListUiState: PolicyFiltersUiModel, + selectedFilterUiState: PolicyFiltersUiModel, + onClassificationCheckChanged: (ClassificationUiModel) -> Unit, + onRegionCheckChanged: (RegionUiModel) -> Unit, + scrollState: ScrollState, +) { + Column( + modifier = modifier + .verticalScroll(scrollState) + .padding(horizontal = 24.dp), + ) { + Spacer(modifier = modifier.height(16.dp)) + Text( + text = stringResource(R.string.classification), + style = WithpeaceTheme.typography.title2, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + Spacer(modifier = modifier.height(16.dp)) + LazyRow { + items(3) { + val filterItem = filterListUiState.classifications[it] + if (selectedFilterUiState.classifications.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + color = WithpeaceTheme.colors.SystemBlack, + ) + } + } + } + } + Spacer(modifier = modifier.height(8.dp)) + LazyRow { + items(2) { + val filterItem = filterListUiState.classifications[it + 3] + if (selectedFilterUiState.classifications.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + color = WithpeaceTheme.colors.SystemBlack, + ) + } + } + } + } + Spacer(modifier = modifier.height(16.dp)) + HorizontalDivider( + modifier = modifier.height(1.dp), + color = WithpeaceTheme.colors.SystemGray3, + ) + Spacer(modifier = modifier.height(16.dp)) + Text( + text = stringResource(id = R.string.region), + style = WithpeaceTheme.typography.title2, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + + Spacer(modifier = modifier.height(16.dp)) + FlowRow { + filterListUiState.regions.dropLast(1).forEach { filterItem -> + if (selectedFilterUiState.regions.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, + ), + onClick = { onRegionCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = filterItem.name, + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onRegionCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = filterItem.name, + color = WithpeaceTheme.colors.SystemBlack, + ) + } + } + } + } + Spacer(modifier = Modifier.height(16.dp)) + } +} + +@Composable +private fun FilterFooter( + modifier: Modifier, + onFilterAllOff: () -> Unit, + onSearchWithFilter: () -> Unit, +) { + Column(modifier = modifier.wrapContentHeight()) { + Spacer( + modifier = modifier + .fillMaxWidth() + .height(4.dp) + .background(WithpeaceTheme.colors.SystemGray3), + ) + Row( + modifier = modifier + .padding(horizontal = 24.dp, vertical = 16.dp) + .fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + modifier = modifier, + onClick = { onFilterAllOff() }, + ) { + Text( + text = stringResource(R.string.filter_all_off), + color = WithpeaceTheme.colors.SystemGray1, + style = WithpeaceTheme.typography.body, + ) + } + TextButton( + contentPadding = PaddingValues(vertical = 12.dp, horizontal = 32.dp), + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = WithpeaceTheme.colors.MainPurple, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, + ), + shape = RoundedCornerShape(5.dp), + onClick = { onSearchWithFilter() }, + ) { + Text(text = stringResource(R.string.search)) + } + } + } +} + diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/PolicyFiltersUiModel.kt similarity index 93% rename from feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt rename to core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/PolicyFiltersUiModel.kt index 88c35818..e2ad44f5 100644 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/PolicyFiltersUiModel.kt +++ b/core/ui/src/main/java/com/withpeace/withpeace/core/ui/policy/filtersetting/PolicyFiltersUiModel.kt @@ -1,4 +1,4 @@ -package com.withpeace.withpeace.feature.home.uistate +package com.withpeace.withpeace.core.ui.policy.filtersetting import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel diff --git a/core/ui/src/main/res/drawable/ic_filter.xml b/core/ui/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..e8ad2a36 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,12 @@ + + + diff --git a/core/ui/src/main/res/drawable/ic_filter_close.xml b/core/ui/src/main/res/drawable/ic_filter_close.xml new file mode 100644 index 00000000..3b9176a8 --- /dev/null +++ b/core/ui/src/main/res/drawable/ic_filter_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/core/ui/src/main/res/values/strings.xml b/core/ui/src/main/res/values/strings.xml index 9c31af9b..7b910434 100644 --- a/core/ui/src/main/res/values/strings.xml +++ b/core/ui/src/main/res/values/strings.xml @@ -30,4 +30,14 @@ 복지,문화 참여,권리 기타 + + + 필터 + 필터 닫기 + 더보기 + 정책 분류 이미지 + 지역 + 정책분야 + 전체 해제 + 검색하기 diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 939e9427..b23fff45 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -7,6 +7,7 @@ android { } dependencies { + implementation(libs.skydoves.balloon) implementation(libs.androidx.paging.common) implementation(libs.androidx.pagingCompose) } \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt index 894f4943..8fc7b70d 100644 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeScreen.kt @@ -5,20 +5,25 @@ import androidx.compose.foundation.MutatePriority import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.stopScroll +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues +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.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Card -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.ModalBottomSheet @@ -35,80 +40,65 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp -import androidx.constraintlayout.compose.ConstraintLayout -import androidx.constraintlayout.compose.Dimension import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.paging.LoadState -import androidx.paging.PagingData -import androidx.paging.compose.LazyPagingItems -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.itemKey +import com.skydoves.balloon.BalloonAnimation +import com.skydoves.balloon.BalloonSizeSpec +import com.skydoves.balloon.compose.Balloon +import com.skydoves.balloon.compose.rememberBalloonBuilder +import com.skydoves.balloon.compose.setBackgroundColor import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme -import com.withpeace.withpeace.core.designsystem.util.dropShadow import com.withpeace.withpeace.core.ui.analytics.TrackScreenViewEvent -import com.withpeace.withpeace.core.ui.bookmark.BookmarkButton import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel import com.withpeace.withpeace.core.ui.policy.RegionUiModel -import com.withpeace.withpeace.feature.home.filtersetting.FilterBottomSheet -import com.withpeace.withpeace.feature.home.uistate.HomeUiEvent -import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel -import com.withpeace.withpeace.feature.home.uistate.YouthPolicyUiModel -import kotlinx.coroutines.flow.flowOf +import com.withpeace.withpeace.core.ui.policy.filtersetting.FilterBottomSheet +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.core.ui.post.PostTopicUiModel +import com.withpeace.withpeace.feature.home.uistate.HotPolicyUiState +import com.withpeace.withpeace.feature.home.uistate.RecentPostsUiState +import com.withpeace.withpeace.feature.home.uistate.RecommendPolicyUiState import kotlinx.coroutines.launch @Composable fun HomeRoute( onShowSnackBar: (message: String) -> Unit = {}, - onNavigationSnackBar: (message: String) -> Unit = {}, viewModel: HomeViewModel = hiltViewModel(), + onNavigationSnackBar: (message: String) -> Unit = {}, onPolicyClick: (String) -> Unit, + onPostClick: (PostTopicUiModel) -> Unit, ) { - val youthPolicyPagingData = viewModel.youthPolicyPagingFlow.collectAsLazyPagingItems() - val selectedFilterUiState = viewModel.selectingFilters.collectAsStateWithLifecycle() - - LaunchedEffect(key1 = viewModel.uiEvent) { - viewModel.uiEvent.collect { - when (it) { - HomeUiEvent.BookmarkSuccess -> { - onNavigationSnackBar("관심 목록에 추가되었습니다.") - } - - HomeUiEvent.BookmarkFailure -> { - onShowSnackBar("찜하기에 실패했습니다. 다시 시도해주세요.") - } - - HomeUiEvent.UnBookmarkSuccess -> { - onShowSnackBar("관심목록에서 삭제되었습니다.") - } - } - } - } + val selectingFilterUiState = viewModel.selectingFilters.collectAsStateWithLifecycle() + val recentPosts = viewModel.recentPostsUiState.collectAsStateWithLifecycle() + val hotPolicies = viewModel.hotPolicyUiState.collectAsStateWithLifecycle() + val recommendPolicies = viewModel.recommendPolicyUiState.collectAsStateWithLifecycle() + val completedFilterUiState = viewModel.completedFilters.collectAsStateWithLifecycle() HomeScreen( - youthPolicies = youthPolicyPagingData, - selectedFilterUiState = selectedFilterUiState.value, - onDismissRequest = viewModel::onCancelFilter, + selectedFilterUiState = selectingFilterUiState.value, onClassificationCheckChanged = viewModel::onCheckClassification, onRegionCheckChanged = viewModel::onCheckRegion, onFilterAllOff = viewModel::onFilterAllOff, onSearchWithFilter = viewModel::onCompleteFilter, onCloseFilter = viewModel::onCancelFilter, + onDismissRequest = viewModel::onCancelFilter, + recentPosts = recentPosts.value, + onPostClick = onPostClick, onPolicyClick = onPolicyClick, - onBookmarkClick = viewModel::bookmark + hotPolicyUiState = hotPolicies.value, + recommendPolicyUiState = recommendPolicies.value, + completedFilterState = completedFilterUiState.value, ) } @Composable fun HomeScreen( + recentPosts: RecentPostsUiState, + hotPolicyUiState: HotPolicyUiState, + recommendPolicyUiState: RecommendPolicyUiState, modifier: Modifier = Modifier, - youthPolicies: LazyPagingItems, selectedFilterUiState: PolicyFiltersUiModel, onDismissRequest: () -> Unit, onClassificationCheckChanged: (ClassificationUiModel) -> Unit, @@ -116,145 +106,44 @@ fun HomeScreen( onFilterAllOff: () -> Unit, onSearchWithFilter: () -> Unit, onCloseFilter: () -> Unit, + onPostClick: (PostTopicUiModel) -> Unit, onPolicyClick: (String) -> Unit, - onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, - + completedFilterState: PolicyFiltersUiModel, ) { Column(modifier = modifier.fillMaxSize()) { HomeHeader( modifier = modifier, - onDismissRequest = onDismissRequest, + ) + HorizontalDivider( + modifier = modifier.height(1.dp), + color = WithpeaceTheme.colors.SystemGray3, + ) + ScrollSection( + recentPosts = recentPosts, selectedFilterUiState = selectedFilterUiState, + onDismissRequest = onDismissRequest, onClassificationCheckChanged = onClassificationCheckChanged, onRegionCheckChanged = onRegionCheckChanged, onFilterAllOff = onFilterAllOff, onSearchWithFilter = onSearchWithFilter, onCloseFilter = onCloseFilter, + onPostClick = onPostClick, + onPolicyClick = onPolicyClick, + hotPolicyUiState = hotPolicyUiState, + recommendPolicyUiState = recommendPolicyUiState, + completedFilterState = completedFilterState, ) - HorizontalDivider( - modifier = modifier.height(1.dp), - color = WithpeaceTheme.colors.SystemGray3, - ) - when(youthPolicies.loadState.refresh) { - is LoadState.Loading -> { - Box(Modifier.fillMaxSize()) { - CircularProgressIndicator( - modifier = Modifier.align(Alignment.Center), - color = WithpeaceTheme.colors.MainPurple, - ) - } - } - - is LoadState.Error -> { - Box(Modifier.fillMaxSize()) { - Text( - text = "네트워크 상태를 확인해주세요.", - modifier = Modifier.align(Alignment.Center), - ) - } - } - is LoadState.NotLoading -> { - if (youthPolicies.itemCount == 0) { - Column( - modifier = modifier.fillMaxSize(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Spacer(modifier = modifier.height(213.dp)) - Image( - painter = painterResource(id = R.drawable.ic_no_result), - contentDescription = stringResource( - R.string.no_result, - ), - ) - Spacer(modifier = modifier.height(24.dp)) - Text( - text = "조건에 맞는 정책이 없어요.", - style = WithpeaceTheme.typography.semiBold16Sp, - color = WithpeaceTheme.colors.SystemBlack, - letterSpacing = 0.16.sp, - lineHeight = 21.sp, - ) - Spacer(modifier = modifier.height(8.dp)) - Text( - text = "필터 조건을 변경한 후 다시 시도해 보세요.", - style = WithpeaceTheme.typography.caption, - color = WithpeaceTheme.colors.SystemBlack, - ) - } - } else { - PolicyItems( - modifier, - youthPolicies, - onPolicyClick, - onBookmarkClick = onBookmarkClick, - ) - } - } - } } TrackScreenViewEvent(screenName = "home") } +@OptIn(ExperimentalLayoutApi::class, ExperimentalMaterial3Api::class) @Composable -private fun PolicyItems( - modifier: Modifier, - youthPolicies: LazyPagingItems, +private fun ScrollSection( onPolicyClick: (String) -> Unit, - onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, -) { - Column( - modifier = modifier - .fillMaxSize() - .background(Color(0xFFF8F9FB)) - .padding(horizontal = 24.dp), - ) { - Spacer(modifier = modifier.height(8.dp)) - LazyColumn( - modifier = modifier - .fillMaxSize() - .testTag("home:policies"), - contentPadding = PaddingValues(bottom = 16.dp), - ) { - items( - count = youthPolicies.itemCount, - key = youthPolicies.itemKey { it.id }, - ) { - val youthPolicy = youthPolicies[it] ?: throw IllegalStateException() - Spacer(modifier = modifier.height(8.dp)) - YouthPolicyCard( - modifier = modifier, - youthPolicy = youthPolicy, - onPolicyClick = onPolicyClick, - onBookmarkClick = onBookmarkClick, - ) - } - item { - if (youthPolicies.loadState.append is LoadState.Loading) { - Column( - modifier = modifier - .padding(top = 8.dp) - .fillMaxWidth() - .background( - Color.Transparent, - ), - ) { - CircularProgressIndicator( - modifier.align(Alignment.CenterHorizontally), - color = WithpeaceTheme.colors.MainPurple, - ) - } - } - } - } - - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -private fun HomeHeader( - modifier: Modifier, + recentPosts: RecentPostsUiState, + modifier: Modifier = Modifier, selectedFilterUiState: PolicyFiltersUiModel, onDismissRequest: () -> Unit, onClassificationCheckChanged: (ClassificationUiModel) -> Unit, @@ -262,7 +151,21 @@ private fun HomeHeader( onFilterAllOff: () -> Unit, onSearchWithFilter: () -> Unit, onCloseFilter: () -> Unit, + onPostClick: (PostTopicUiModel) -> Unit, + hotPolicyUiState: HotPolicyUiState, + recommendPolicyUiState: RecommendPolicyUiState, + completedFilterState: PolicyFiltersUiModel, ) { + val builder = rememberBalloonBuilder { + setIsVisibleArrow(false) + setWidth(BalloonSizeSpec.WRAP) + setHeight(BalloonSizeSpec.WRAP) + setPadding(12) + setCornerRadius(12f) + setBackgroundColor(Color(0xFFF9FBFB)) + setBalloonAnimation(BalloonAnimation.FADE) + setArrowSize(0) + } var showBottomSheet by remember { mutableStateOf(false) } val bottomSheetChildScrollState = rememberScrollState() @@ -277,30 +180,6 @@ private fun HomeHeader( } } - Box( - modifier = modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(bottom = 16.dp), - ) { - Image( - modifier = modifier - .align(Alignment.BottomCenter) - .size(47.dp), - painter = painterResource(id = R.drawable.ic_home_logo), - contentDescription = stringResource(R.string.cheongha_logo), - ) - Image( - modifier = modifier - .size(24.dp) - .align(Alignment.CenterEnd) - .clickable { - showBottomSheet = true - }, - painter = painterResource(id = R.drawable.ic_filter), - contentDescription = stringResource(R.string.filter), - ) - } if (showBottomSheet) { ModalBottomSheet( modifier = modifier, @@ -333,180 +212,293 @@ private fun HomeHeader( ) } } -} -@Composable -private fun YouthPolicyCard( - modifier: Modifier, - youthPolicy: YouthPolicyUiModel, - onPolicyClick: (String) -> Unit, - onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, -) { - Card( - modifier = modifier - .fillMaxWidth() - .background( - color = WithpeaceTheme.colors.SystemWhite, - shape = RoundedCornerShape(size = 10.dp), - ) - .dropShadow( - color = Color(0x1A000000), - blurRadius = 4.dp, - spreadRadius = 2.dp, - borderRadius = 10.dp, - ) - .clickable { - onPolicyClick(youthPolicy.id) - }, + LazyColumn( + verticalArrangement = Arrangement.spacedBy(8.dp), + contentPadding = PaddingValues(bottom = 24.dp), ) { - ConstraintLayout( - modifier = modifier - .fillMaxWidth() - .background(WithpeaceTheme.colors.SystemWhite) - .padding(16.dp), - ) { - val ( - title, content, - region, ageRange, thumbnail, heart, - ) = createRefs() + item { + Column( + modifier = modifier + .fillMaxWidth() + .background(color = WithpeaceTheme.colors.Gray3_70) + .padding(horizontal = 24.dp, vertical = 12.dp), + ) { + Row{ + Text( + text = "관심 키워드", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemGray1, + ) + Spacer(modifier = modifier.width(4.dp)) + Balloon( + builder = builder, + balloonContent = { + Text( + text = "선택하신 관심 정책 분야 및 지역으로,\n" + + "핫한 정책 및 맞춤정책 추천 시 해당 키워드 기반으\n로 추천이 진행됩니다.", + color = Color(0x9C101014), + style = WithpeaceTheme.typography.homePolicyContent, + ) + }, + ) { balloonWindow -> + Image( + modifier = modifier.clickable { + balloonWindow.showAsDropDown() + }, + painter = painterResource(id = R.drawable.ic_circle_info), + contentDescription = "", + ) + } + } + Spacer(modifier = modifier.height(8.dp)) + FlowRow(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Image( + painter = painterResource(id = R.drawable.ic_filter), + modifier = modifier + .background( + color = WithpeaceTheme.colors.SubPurple, + shape = CircleShape, + ) + .padding(4.dp) + .size(16.dp) + .clickable { + showBottomSheet = true + }, + contentDescription = "", + ) + List(completedFilterState.classifications.size) { //TODO("데이터 변경") + Spacer(modifier = modifier.width(8.dp)) + Text( + text = "#${stringResource(id = completedFilterState.classifications[it].stringResId)}", + style = WithpeaceTheme.typography.Tag, + color = WithpeaceTheme.colors.MainPurple, + modifier = modifier + .background( + color = WithpeaceTheme.colors.SubPurple, + shape = RoundedCornerShape(7.dp), + ) + .padding(6.dp), + ) + } + List(completedFilterState.regions.size) { //TODO("데이터 변경") + Spacer(modifier = modifier.width(8.dp)) + Text( + text = "#${completedFilterState.regions[it].name}", + style = WithpeaceTheme.typography.Tag, + color = WithpeaceTheme.colors.MainPurple, + modifier = modifier + .background( + color = WithpeaceTheme.colors.SubPurple, + shape = RoundedCornerShape(7.dp), + ) + .padding(6.dp), + ) + } + } + } + Spacer(modifier = modifier.height(16.dp)) Text( - text = youthPolicy.title, - modifier.constrainAs( - title, - constrainBlock = { - top.linkTo(parent.top) - start.linkTo(parent.start) - end.linkTo(thumbnail.start, margin = 8.dp) - width = Dimension.fillToConstraints - }, - ), - color = WithpeaceTheme.colors.SystemBlack, - style = WithpeaceTheme.typography.homePolicyTitle, - overflow = TextOverflow.Ellipsis, - maxLines = 1, + modifier = modifier.padding(horizontal = 24.dp), + text = "지금 핫한 정책", + color = WithpeaceTheme.colors.SnackbarBlack, + style = WithpeaceTheme.typography.title2, ) + Spacer(modifier = modifier.height(16.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (hotPolicyUiState is HotPolicyUiState.Success) { + items(hotPolicyUiState.policies.size) { + val hotPolicy = hotPolicyUiState.policies[it] + Column( + modifier.clickable { + onPolicyClick(hotPolicy.id) + }, + ) { + Image( + modifier = modifier + .size(140.dp) + .clip(RoundedCornerShape(10.dp)), + painter = painterResource(id = hotPolicy.classification.drawableResId), + contentDescription = "", + ) + Spacer(modifier = modifier.height(8.dp)) + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = modifier.width(140.dp), + text = hotPolicy.title, + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + } + } + } else { + //TODO 실패 UI + items(6) { + Column( + modifier.clickable { + onPolicyClick("R2024092726752") + }, + ) { + Image( + modifier = modifier + .size(140.dp) + .clip(RoundedCornerShape(10.dp)), + painter = painterResource(id = com.withpeace.withpeace.core.ui.R.drawable.ic_policy_participation_right), + contentDescription = "", + ) + Spacer(modifier = modifier.height(8.dp)) + Text( + modifier = modifier.width(140.dp), + text = "울산대학교 대학일자리플러스센터(거점형)", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + } + } + } + } + Spacer(modifier = modifier.height(24.dp)) Text( - text = youthPolicy.content, - modifier = modifier - .constrainAs( - content, - constrainBlock = { - top.linkTo(title.bottom, margin = 8.dp) - start.linkTo(parent.start) - end.linkTo(thumbnail.start, margin = 8.dp) - width = Dimension.fillToConstraints - }, - ), - color = WithpeaceTheme.colors.SystemBlack, - style = WithpeaceTheme.typography.homePolicyContent, - overflow = TextOverflow.Ellipsis, - maxLines = 2, + modifier = modifier.padding(horizontal = 24.dp), + text = "맞춤 정책을 추천해드릴게요!", + color = WithpeaceTheme.colors.SnackbarBlack, + style = WithpeaceTheme.typography.title2, ) - BookmarkButton( - isClicked = youthPolicy.isBookmarked, - modifier = modifier.constrainAs( - heart, - ) { - top.linkTo(content.bottom, margin = 8.dp) - start.linkTo(parent.start) - bottom.linkTo(parent.bottom) - }, - onClick = { isClicked -> - onBookmarkClick(youthPolicy.id, isClicked) - }, - ) - + Spacer(modifier = modifier.height(8.dp)) Text( - text = youthPolicy.region.name, - color = WithpeaceTheme.colors.MainPurple, - modifier = modifier - .constrainAs( - region, - constrainBlock = { - top.linkTo(heart.top) - start.linkTo(heart.end, margin = 8.dp) - bottom.linkTo(heart.bottom) - }, - ) - .background( - color = WithpeaceTheme.colors.SubPurple, - shape = RoundedCornerShape(size = 5.dp), - ) - .padding(horizontal = 8.dp, vertical = 4.dp), - style = WithpeaceTheme.typography.Tag, + modifier = modifier.padding(horizontal = 24.dp), + text = "관심 키워드와 추천 알고리즘을 기반으로 정책을 추천해드려요.", + style = WithpeaceTheme.typography.homePolicyContent, + color = WithpeaceTheme.colors.SystemGray1, ) - + Spacer(modifier = modifier.height(16.dp)) + LazyRow( + contentPadding = PaddingValues(horizontal = 24.dp), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (recommendPolicyUiState is RecommendPolicyUiState.Success) { + items(recommendPolicyUiState.policies.size) { + val recommentPolicy = recommendPolicyUiState.policies[it] + Column( + modifier.clickable { + onPolicyClick(recommentPolicy.id) + }, + ) { + Image( + modifier = modifier + .size(140.dp) + .clip(RoundedCornerShape(10.dp)), + painter = painterResource(id = recommentPolicy.classification.drawableResId), + contentDescription = "", + ) + Spacer(modifier = modifier.height(8.dp)) + Text( + overflow = TextOverflow.Ellipsis, + maxLines = 2, + modifier = modifier.width(140.dp), + text = recommentPolicy.title, + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + } + } + } else { + //TODO 실패 UI + items(6) { + Column( + modifier.clickable { + onPolicyClick("R2024092726752") + }, + ) { + Image( + modifier = modifier + .size(140.dp) + .clip(RoundedCornerShape(10.dp)), + painter = painterResource(id = com.withpeace.withpeace.core.ui.R.drawable.ic_policy_participation_right), + contentDescription = "", + ) + Spacer(modifier = modifier.height(8.dp)) + Text( + modifier = modifier.width(140.dp), + text = "울산대학교 대학일자리플러스센터(거점형)", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SnackbarBlack, + ) + } + } + } + } + Spacer(modifier = modifier.height(24.dp)) Text( - text = youthPolicy.ageInfo, - color = WithpeaceTheme.colors.SystemGray1, - modifier = modifier - .constrainAs( - ageRange, - constrainBlock = { - top.linkTo(region.top) - start.linkTo(region.end, margin = 8.dp) - bottom.linkTo(region.bottom) + modifier = modifier.padding(horizontal = 24.dp), + text = "커뮤니티", + color = WithpeaceTheme.colors.SnackbarBlack, + style = WithpeaceTheme.typography.title2, + ) + Spacer(modifier = modifier.height(16.dp)) + } + if (recentPosts is RecentPostsUiState.Success) { + items(PostTopicUiModel.entries.size) { + val postTopic = PostTopicUiModel.entries[it] + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .padding(start = 24.dp, end = 17.dp) + .clickable { + onPostClick(postTopic) }, + ) { + Text( + text = stringResource(id = PostTopicUiModel.entries[it].textResId) + "게시판", + style = WithpeaceTheme.typography.body, + color = WithpeaceTheme.colors.SnackbarBlack, ) - .background( - color = WithpeaceTheme.colors.SystemGray3, - shape = RoundedCornerShape(size = 5.dp), + Spacer(modifier = modifier.width(16.dp)) + Text( + color = WithpeaceTheme.colors.SystemGray1, + style = WithpeaceTheme.typography.caption, + text = recentPosts.recentPosts.find { postTopic == it.type }?.title ?: "-", + maxLines = 1, + overflow = TextOverflow.Ellipsis, ) - .padding(horizontal = 8.dp, vertical = 4.dp), - style = WithpeaceTheme.typography.Tag, - ) - - - Image( - modifier = modifier - .size(57.dp) - .clip(RoundedCornerShape(10.dp)) - .constrainAs( - ref = thumbnail, - constrainBlock = { - start.linkTo(title.end) - end.linkTo(parent.end) - top.linkTo(parent.top) - }, - ), - painter = painterResource(id = youthPolicy.classification.drawableResId), - contentDescription = stringResource(R.string.policy_classification_image), - ) + } + } } + } } @Composable -@Preview(showBackground = true) -fun HomePreview() { - WithpeaceTheme { - HomeScreen( - youthPolicies = - flowOf( - PagingData.from( - listOf( - YouthPolicyUiModel( - id = "deterruisset", - title = "epicurei", - content = "interdum", - region = RegionUiModel.대구, - ageInfo = "quo", - classification = ClassificationUiModel.JOB, - isBookmarked = false, - ), - ), - ), - ).collectAsLazyPagingItems(), - selectedFilterUiState = PolicyFiltersUiModel(), - onDismissRequest = { }, - onClassificationCheckChanged = {}, - onRegionCheckChanged = {}, - onFilterAllOff = {}, - onSearchWithFilter = {}, - onCloseFilter = {}, - onPolicyClick = {}, - onBookmarkClick = { id, isChecked->} +private fun HomeHeader( + modifier: Modifier, +) { + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + ) { + Image( + modifier = modifier + .align(Alignment.BottomCenter) + .size(47.dp), + painter = painterResource(id = R.drawable.ic_home_logo), + contentDescription = stringResource(R.string.cheongha_logo), ) + // Image( + // modifier = modifier + // .size(24.dp) + // .align(Alignment.CenterEnd) + // .clickable { + // showBottomSheet = true + // }, + // painter = painterResource(id = R.drawable.ic_filter), + // contentDescription = stringResource(R.string.filter), + // ) } -} +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt index a38bb3d3..55794f2b 100644 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/HomeViewModel.kt @@ -2,65 +2,53 @@ package com.withpeace.withpeace.feature.home import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.paging.PagingData -import androidx.paging.cachedIn -import androidx.paging.map -import com.withpeace.withpeace.core.domain.extension.groupBy -import com.withpeace.withpeace.core.domain.model.error.ResponseError -import com.withpeace.withpeace.core.domain.model.policy.BookmarkInfo import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters -import com.withpeace.withpeace.core.domain.usecase.BookmarkPolicyUseCase -import com.withpeace.withpeace.core.domain.usecase.GetYouthPoliciesUseCase +import com.withpeace.withpeace.core.domain.usecase.GetHotPoliciesUseCase +import com.withpeace.withpeace.core.domain.usecase.GetPolicyFilterUseCase +import com.withpeace.withpeace.core.domain.usecase.GetRecentPostUseCase +import com.withpeace.withpeace.core.domain.usecase.GetRecommendPoliciesUseCase +import com.withpeace.withpeace.core.domain.usecase.UpdatePolicyFilterUseCase import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel import com.withpeace.withpeace.core.ui.policy.RegionUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.toDomain +import com.withpeace.withpeace.core.ui.policy.filtersetting.toUiModel import com.withpeace.withpeace.core.ui.policy.toDomain -import com.withpeace.withpeace.feature.home.uistate.HomeUiEvent -import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel -import com.withpeace.withpeace.feature.home.uistate.YouthPolicyUiModel -import com.withpeace.withpeace.feature.home.uistate.toDomain +import com.withpeace.withpeace.feature.home.uistate.HotPolicyUiState +import com.withpeace.withpeace.feature.home.uistate.RecentPostsUiState +import com.withpeace.withpeace.feature.home.uistate.RecommendPolicyUiState import com.withpeace.withpeace.feature.home.uistate.toUiModel import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.firstOrNull -import kotlinx.coroutines.flow.flatMapMerge import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @HiltViewModel class HomeViewModel @Inject constructor( - private val youthPoliciesUseCase: GetYouthPoliciesUseCase, - private val bookmarkPolicyUseCase: BookmarkPolicyUseCase, + private val getRecentPostUseCase: GetRecentPostUseCase, + private val getRecommendPoliciesUseCase: GetRecommendPoliciesUseCase, + private val getHotPoliciesUseCase: GetHotPoliciesUseCase, + private val getPolicyFilterUseCase: GetPolicyFilterUseCase, + private val updatePolicyFilterUseCase: UpdatePolicyFilterUseCase, ) : ViewModel() { - private val bookmarkStateFlow = - MutableStateFlow(mapOf()) // paging 처리를 위한 북마크 여부 상태 홀더 - - private val _youthPolicyPagingFlow = MutableStateFlow(PagingData.empty()) - val youthPolicyPagingFlow = - combine( - _youthPolicyPagingFlow.asStateFlow(), - bookmarkStateFlow, - ) { youthPolicyPagingFlow, bookmarkFlow -> - youthPolicyPagingFlow.map { - lastByWhetherSuccessOfBookmarks[it.id] = it.isBookmarked - val bookmarkState = bookmarkFlow[it.id] - it.copy(isBookmarked = bookmarkState ?: it.isBookmarked) - } - }.cachedIn(viewModelScope) + private val _recentPostsUiState: MutableStateFlow = + MutableStateFlow(RecentPostsUiState.Loading) + val recentPostsUiState = _recentPostsUiState.asStateFlow() + + private val _recommendPolicyUiState: MutableStateFlow = + MutableStateFlow(RecommendPolicyUiState.Loading) + val recommendPolicyUiState = _recommendPolicyUiState.asStateFlow() + + private val _hotPolicyUiState: MutableStateFlow = + MutableStateFlow(HotPolicyUiState.Loading) + val hotPolicyUiState = _hotPolicyUiState.asStateFlow() private val _selectingFilters = MutableStateFlow(PolicyFilters()) val selectingFilters: StateFlow = @@ -70,63 +58,72 @@ class HomeViewModel @Inject constructor( PolicyFiltersUiModel(), ) - private val _uiEvent = Channel() - val uiEvent = _uiEvent.receiveAsFlow() - - private val debounceFlow = MutableSharedFlow(replay = 1) - - private val lastByWhetherSuccessOfBookmarks = - mutableMapOf() // optimistic UI에서 실패시에 사용할 캐시 데이터 + private val _completedFilters = MutableStateFlow(PolicyFilters()) + val completedFilters: StateFlow = + _completedFilters.map { it.toUiModel() }.stateIn( + scope = viewModelScope, + SharingStarted.WhileSubscribed(), + PolicyFiltersUiModel(), + ) - private var completedFilters = PolicyFilters() init { viewModelScope.launch { - debounceFlow.groupBy { it.id }.flatMapMerge { - it.second.debounce(300L) - }.collectLatest { bookmarkInfo -> // policyBookmarkViewModel과 다른 이유를 찾아보기 - bookmarkPolicyUseCase( - bookmarkInfo.id, bookmarkInfo.isBookmarked, + launch { + getRecentPostUseCase( onError = { - bookmarkStateFlow.update { - it + mapOf( - bookmarkInfo.id to (lastByWhetherSuccessOfBookmarks[bookmarkInfo.id] - ?: !bookmarkInfo.isBookmarked), - ) - } - _uiEvent.send(HomeUiEvent.BookmarkFailure) + }, - ).collect { result -> - lastByWhetherSuccessOfBookmarks[result.id] = result.isBookmarked - if (result.isBookmarked) { - _uiEvent.send(HomeUiEvent.BookmarkSuccess) - } else { - _uiEvent.send(HomeUiEvent.UnBookmarkSuccess) + ).collect { data -> + _recentPostsUiState.update { + RecentPostsUiState.Success( + data.map { it.toUiModel() }, + ) } } - + } + getRecommendPolicy() + getHotPolicy() + launch { + getPolicyFilterUseCase( + onError = { + }, + ).collect { data -> + _selectingFilters.update { data } + _completedFilters.update { data } + } } } - fetchData() } + private fun CoroutineScope.getHotPolicy() { + launch { + getHotPoliciesUseCase( + onError = { + _hotPolicyUiState.update { + HotPolicyUiState.Failure + } + }, + ).collect { data -> + _hotPolicyUiState.update { + HotPolicyUiState.Success(data.map { it.toUiModel() }) + } + } + } + } - private fun fetchData() { - viewModelScope.launch { - _youthPolicyPagingFlow.update { - youthPoliciesUseCase( - filterInfo = completedFilters, - onError = { - when (it) { - ResponseError.EXPIRED_TOKEN_ERROR -> {} - else -> {} - } - }, - ).map { - it.map { youthPolicy -> - youthPolicy.toUiModel() + private fun CoroutineScope.getRecommendPolicy() { + launch { + getRecommendPoliciesUseCase( + onError = { + _recommendPolicyUiState.update { + RecommendPolicyUiState.Failure } - }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty() + }, + ).collect { data -> + _recommendPolicyUiState.update { + RecommendPolicyUiState.Success(data.map { it.toUiModel() }) + } } } } @@ -144,12 +141,20 @@ class HomeViewModel @Inject constructor( } fun onCompleteFilter() { - completedFilters = selectingFilters.value.toDomain() - fetchData() + viewModelScope.launch { + updatePolicyFilterUseCase( + policyFilters = selectingFilters.value.toDomain(), + onError = {}, + ).collect { + _completedFilters.update { selectingFilters.value.toDomain() } + this.launch { getHotPolicy() } + this.launch { getRecommendPolicy() } + } + } } fun onCancelFilter() { - _selectingFilters.update { completedFilters } + _selectingFilters.update { completedFilters.value.toDomain() } } fun onFilterAllOff() { @@ -157,11 +162,4 @@ class HomeViewModel @Inject constructor( it.removeAll() } } - - fun bookmark(id: String, isChecked: Boolean) { - bookmarkStateFlow.update { it + mapOf(id to isChecked) } - viewModelScope.launch { - debounceFlow.emit(BookmarkInfo(id, isChecked)) - } - } } diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt deleted file mode 100644 index 50e7e366..00000000 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/uistate/FilterListUiState.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.withpeace.withpeace.feature.home.filtersetting.uistate - -import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel -import com.withpeace.withpeace.core.ui.policy.RegionUiModel -import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel - -data class FilterListUiState( - val isClassificationExpanded: Boolean = false, - val isRegionExpanded: Boolean = false, -) { - private val allClassifications: List = ClassificationUiModel.entries - private val allRegions: List = RegionUiModel.entries - - fun getStateByFilterState(filtersUiModel: PolicyFiltersUiModel): FilterListUiState { - return this.copy( - isClassificationExpanded = allClassifications.indexOf(filtersUiModel.classifications.lastOrNull()) >= FOLDED_CLASSIFICATION_ITEM_COUNT, - isRegionExpanded = allRegions.indexOf(filtersUiModel.regions.lastOrNull()) >= FOLDED_REGION_ITEM_COUNT, - ) - } - - fun getClassifications(): List { - return if (isClassificationExpanded) allClassifications.dropLast(ETC_COUNT) - else allClassifications.subList(0, FOLDED_CLASSIFICATION_ITEM_COUNT) - } - - fun getRegions(): List { - return if (isRegionExpanded) allRegions.dropLast(ETC_COUNT) - else allRegions.subList(0, FOLDED_REGION_ITEM_COUNT) - } - - companion object { - private const val FOLDED_CLASSIFICATION_ITEM_COUNT = 3 - private const val FOLDED_REGION_ITEM_COUNT = 7 - private const val ETC_COUNT = 1 - } -} \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt index 7efef378..1b619349 100644 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/navigation/HomeNavigation.kt @@ -6,6 +6,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions import androidx.navigation.compose.composable +import com.withpeace.withpeace.core.ui.post.PostTopicUiModel import com.withpeace.withpeace.feature.home.HomeRoute const val HOME_ROUTE = "homeRoute" @@ -18,6 +19,7 @@ fun NavGraphBuilder.homeNavGraph( onShowSnackBar: (message: String) -> Unit, onNavigationSnackBar: (message: String) -> Unit = {}, onPolicyClick: (String) -> Unit, + onPostClick: (PostTopicUiModel) -> Unit, ) { composable( route = HOME_ROUTE, @@ -28,6 +30,7 @@ fun NavGraphBuilder.homeNavGraph( onNavigationSnackBar = onNavigationSnackBar, onShowSnackBar = onShowSnackBar, onPolicyClick = onPolicyClick, + onPostClick = onPostClick, ) } } \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/HotPolicyUiState.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/HotPolicyUiState.kt new file mode 100644 index 00000000..7d51cce8 --- /dev/null +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/HotPolicyUiState.kt @@ -0,0 +1,7 @@ +package com.withpeace.withpeace.feature.home.uistate + +sealed interface HotPolicyUiState { + data class Success(val policies: List) : HotPolicyUiState + data object Loading : HotPolicyUiState + data object Failure : HotPolicyUiState +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiMapper.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiMapper.kt new file mode 100644 index 00000000..1805425e --- /dev/null +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiMapper.kt @@ -0,0 +1,10 @@ +package com.withpeace.withpeace.feature.home.uistate + +import com.withpeace.withpeace.core.domain.model.post.RecentPost +import com.withpeace.withpeace.core.ui.post.toUi + +fun RecentPost.toUiModel(): RecentPostUiModel { + return RecentPostUiModel( + id = id, title = title, type = type.toUi(), + ) +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiState.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiState.kt new file mode 100644 index 00000000..c528bf6f --- /dev/null +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecentPostsUiState.kt @@ -0,0 +1,16 @@ +package com.withpeace.withpeace.feature.home.uistate + +import com.withpeace.withpeace.core.domain.model.post.PostTopic +import com.withpeace.withpeace.core.ui.post.PostTopicUiModel + +sealed interface RecentPostsUiState { + data class Success(val recentPosts: List) : RecentPostsUiState + data object Loading : RecentPostsUiState + data object Failure : RecentPostsUiState +} + +data class RecentPostUiModel( + val id: Long, + val title: String, + val type: PostTopicUiModel, +) \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecommendPolicyUiState.kt b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecommendPolicyUiState.kt new file mode 100644 index 00000000..3e878beb --- /dev/null +++ b/feature/home/src/main/java/com/withpeace/withpeace/feature/home/uistate/RecommendPolicyUiState.kt @@ -0,0 +1,7 @@ +package com.withpeace.withpeace.feature.home.uistate + +sealed interface RecommendPolicyUiState { + data class Success(val policies: List) : RecommendPolicyUiState + data object Loading : RecommendPolicyUiState + data object Failure : RecommendPolicyUiState +} \ No newline at end of file diff --git a/feature/home/src/main/res/drawable/ic_circle_info.xml b/feature/home/src/main/res/drawable/ic_circle_info.xml new file mode 100644 index 00000000..20635334 --- /dev/null +++ b/feature/home/src/main/res/drawable/ic_circle_info.xml @@ -0,0 +1,11 @@ + + + diff --git a/feature/policylist/.gitignore b/feature/policylist/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/policylist/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/policylist/build.gradle.kts b/feature/policylist/build.gradle.kts new file mode 100644 index 00000000..0bb33277 --- /dev/null +++ b/feature/policylist/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + id("convention.feature") +} + +android { + namespace = "com.withpeace.withpeace.feature.policylist" +} + +dependencies { + implementation(libs.androidx.paging.common) + implementation(libs.androidx.pagingCompose) +} \ No newline at end of file diff --git a/feature/policylist/consumer-rules.pro b/feature/policylist/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/policylist/proguard-rules.pro b/feature/policylist/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/policylist/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file diff --git a/feature/policylist/src/androidTest/java/com/withpeace/withpeace/feature/policylist/ExampleInstrumentedTest.kt b/feature/policylist/src/androidTest/java/com/withpeace/withpeace/feature/policylist/ExampleInstrumentedTest.kt new file mode 100644 index 00000000..867cd9cf --- /dev/null +++ b/feature/policylist/src/androidTest/java/com/withpeace/withpeace/feature/policylist/ExampleInstrumentedTest.kt @@ -0,0 +1,24 @@ +package com.withpeace.withpeace.feature.policylist + +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.ext.junit.runners.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumented test, which will execute on an Android device. + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getInstrumentation().targetContext + assertEquals("com.withpeace.withpeace.feature.policylist.test", appContext.packageName) + } +} \ No newline at end of file diff --git a/feature/policylist/src/main/AndroidManifest.xml b/feature/policylist/src/main/AndroidManifest.xml new file mode 100644 index 00000000..a5918e68 --- /dev/null +++ b/feature/policylist/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt new file mode 100644 index 00000000..c7eeba42 --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListScreen.kt @@ -0,0 +1,511 @@ +package com.withpeace.withpeace.feature.policylist + +import androidx.compose.foundation.Image +import androidx.compose.foundation.MutatePriority +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.stopScroll +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +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.lazy.LazyColumn +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Text +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +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.Color +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.constraintlayout.compose.ConstraintLayout +import androidx.constraintlayout.compose.Dimension +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.paging.LoadState +import androidx.paging.PagingData +import androidx.paging.compose.LazyPagingItems +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme +import com.withpeace.withpeace.core.designsystem.util.dropShadow +import com.withpeace.withpeace.core.ui.analytics.TrackScreenViewEvent +import com.withpeace.withpeace.core.ui.bookmark.BookmarkButton +import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel +import com.withpeace.withpeace.core.ui.policy.RegionUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.FilterBottomSheet +import com.withpeace.withpeace.feature.policylist.uistate.PolicyListUiEvent +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.feature.policylist.uistate.YouthPolicyUiModel +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + +@Composable +fun PolicyListRoute( + onShowSnackBar: (message: String) -> Unit = {}, + onNavigationSnackBar: (message: String) -> Unit = {}, + viewModel: PolicyListViewModel = hiltViewModel(), + onPolicyClick: (String) -> Unit, +) { + val youthPolicyPagingData = viewModel.youthPolicyPagingFlow.collectAsLazyPagingItems() + val selectedFilterUiState = viewModel.selectingFilters.collectAsStateWithLifecycle() + + LaunchedEffect(key1 = viewModel.uiEvent) { + viewModel.uiEvent.collect { + when (it) { + PolicyListUiEvent.BookmarkSuccess -> { + onNavigationSnackBar("관심 목록에 추가되었습니다.") + } + + PolicyListUiEvent.BookmarkFailure -> { + onShowSnackBar("찜하기에 실패했습니다. 다시 시도해주세요.") + } + + PolicyListUiEvent.UnBookmarkSuccess -> { + onShowSnackBar("관심목록에서 삭제되었습니다.") + } + } + } + } + PolicyListScreen( + youthPolicies = youthPolicyPagingData, + selectedFilterUiState = selectedFilterUiState.value, + onDismissRequest = viewModel::onCancelFilter, + onClassificationCheckChanged = viewModel::onCheckClassification, + onRegionCheckChanged = viewModel::onCheckRegion, + onFilterAllOff = viewModel::onFilterAllOff, + onSearchWithFilter = viewModel::onCompleteFilter, + onCloseFilter = viewModel::onCancelFilter, + onPolicyClick = onPolicyClick, + onBookmarkClick = viewModel::bookmark, + ) +} + +@Composable +fun PolicyListScreen( + modifier: Modifier = Modifier, + youthPolicies: LazyPagingItems, + selectedFilterUiState: PolicyFiltersUiModel, + onDismissRequest: () -> Unit, + onClassificationCheckChanged: (ClassificationUiModel) -> Unit, + onRegionCheckChanged: (RegionUiModel) -> Unit, + onFilterAllOff: () -> Unit, + onSearchWithFilter: () -> Unit, + onCloseFilter: () -> Unit, + onPolicyClick: (String) -> Unit, + onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, + + ) { + Column(modifier = modifier.fillMaxSize()) { + HomeHeader( + modifier = modifier, + onDismissRequest = onDismissRequest, + selectedFilterUiState = selectedFilterUiState, + onClassificationCheckChanged = onClassificationCheckChanged, + onRegionCheckChanged = onRegionCheckChanged, + onFilterAllOff = onFilterAllOff, + onSearchWithFilter = onSearchWithFilter, + onCloseFilter = onCloseFilter, + ) + HorizontalDivider( + modifier = modifier.height(1.dp), + color = WithpeaceTheme.colors.SystemGray3, + ) + when (youthPolicies.loadState.refresh) { + is LoadState.Loading -> { + Box(Modifier.fillMaxSize()) { + CircularProgressIndicator( + modifier = Modifier.align(Alignment.Center), + color = WithpeaceTheme.colors.MainPurple, + ) + } + } + + is LoadState.Error -> { + Box(Modifier.fillMaxSize()) { + Text( + text = "네트워크 상태를 확인해주세요.", + modifier = Modifier.align(Alignment.Center), + ) + } + } + + is LoadState.NotLoading -> { + if (youthPolicies.itemCount == 0) { + Column( + modifier = modifier.fillMaxSize(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = modifier.height(213.dp)) + Image( + painter = painterResource(id = R.drawable.ic_no_result), + contentDescription = stringResource( + R.string.no_result, + ), + ) + Spacer(modifier = modifier.height(24.dp)) + Text( + text = "조건에 맞는 정책이 없어요.", + style = WithpeaceTheme.typography.semiBold16Sp, + color = WithpeaceTheme.colors.SystemBlack, + letterSpacing = 0.16.sp, + lineHeight = 21.sp, + ) + Spacer(modifier = modifier.height(8.dp)) + Text( + text = "필터 조건을 변경한 후 다시 시도해 보세요.", + style = WithpeaceTheme.typography.caption, + color = WithpeaceTheme.colors.SystemBlack, + ) + } + } else { + PolicyItems( + modifier, + youthPolicies, + onPolicyClick, + onBookmarkClick = onBookmarkClick, + ) + } + } + } + } + TrackScreenViewEvent(screenName = "policyList") +} + +@Composable +private fun PolicyItems( + modifier: Modifier, + youthPolicies: LazyPagingItems, + onPolicyClick: (String) -> Unit, + onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, +) { + Column( + modifier = modifier + .fillMaxSize() + .background(Color(0xFFF8F9FB)) + .padding(horizontal = 24.dp), + ) { + Spacer(modifier = modifier.height(8.dp)) + LazyColumn( + modifier = modifier + .fillMaxSize() + .testTag("home:policies"), + contentPadding = PaddingValues(bottom = 16.dp), + ) { + items( + count = youthPolicies.itemCount, + key = youthPolicies.itemKey { it.id }, + ) { + val youthPolicy = youthPolicies[it] ?: throw IllegalStateException() + Spacer(modifier = modifier.height(8.dp)) + YouthPolicyCard( + modifier = modifier, + youthPolicy = youthPolicy, + onPolicyClick = onPolicyClick, + onBookmarkClick = onBookmarkClick, + ) + } + item { + if (youthPolicies.loadState.append is LoadState.Loading) { + Column( + modifier = modifier + .padding(top = 8.dp) + .fillMaxWidth() + .background( + Color.Transparent, + ), + ) { + CircularProgressIndicator( + modifier.align(Alignment.CenterHorizontally), + color = WithpeaceTheme.colors.MainPurple, + ) + } + } + } + } + + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun HomeHeader( + modifier: Modifier, + selectedFilterUiState: PolicyFiltersUiModel, + onDismissRequest: () -> Unit, + onClassificationCheckChanged: (ClassificationUiModel) -> Unit, + onRegionCheckChanged: (RegionUiModel) -> Unit, + onFilterAllOff: () -> Unit, + onSearchWithFilter: () -> Unit, + onCloseFilter: () -> Unit, +) { + var showBottomSheet by remember { mutableStateOf(false) } + val bottomSheetChildScrollState = rememberScrollState() + + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true, + ) + val scope = rememberCoroutineScope() + + LaunchedEffect(bottomSheetChildScrollState.canScrollBackward) { + if (bottomSheetChildScrollState.value == 0) { + bottomSheetChildScrollState.stopScroll(MutatePriority.PreventUserInput) + } + } + + Box( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(bottom = 16.dp), + ) { + Image( + modifier = modifier + .align(Alignment.BottomCenter) + .size(47.dp), + painter = painterResource(id = R.drawable.ic_home_logo), + contentDescription = stringResource(R.string.cheongha_logo), + ) + Image( + modifier = modifier + .size(24.dp) + .align(Alignment.CenterEnd) + .clickable { + showBottomSheet = true + }, + painter = painterResource(id = R.drawable.ic_filter), + contentDescription = stringResource(R.string.filter), + ) + } + if (showBottomSheet) { + ModalBottomSheet( + modifier = modifier, + dragHandle = null, + onDismissRequest = { + onDismissRequest() + showBottomSheet = false + }, + sheetState = sheetState, + ) { + FilterBottomSheet( + modifier = modifier, + scrollState = bottomSheetChildScrollState, + selectedFilterUiState = selectedFilterUiState, + onClassificationCheckChanged = onClassificationCheckChanged, + onRegionCheckChanged = onRegionCheckChanged, + onFilterAllOff = onFilterAllOff, + onSearchWithFilter = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + showBottomSheet = false + onSearchWithFilter() + } + }, + onCloseFilter = { + scope.launch { sheetState.hide() }.invokeOnCompletion { + showBottomSheet = false + onCloseFilter() + } + }, + ) + } + } +} + +@Composable +private fun YouthPolicyCard( + modifier: Modifier, + youthPolicy: YouthPolicyUiModel, + onPolicyClick: (String) -> Unit, + onBookmarkClick: (id: String, isChecked: Boolean) -> Unit, +) { + Card( + modifier = modifier + .fillMaxWidth() + .background( + color = WithpeaceTheme.colors.SystemWhite, + shape = RoundedCornerShape(size = 10.dp), + ) + .dropShadow( + color = Color(0x1A000000), + blurRadius = 4.dp, + spreadRadius = 2.dp, + borderRadius = 10.dp, + ) + .clickable { + onPolicyClick(youthPolicy.id) + }, + ) { + ConstraintLayout( + modifier = modifier + .fillMaxWidth() + .background(WithpeaceTheme.colors.SystemWhite) + .padding(16.dp), + ) { + val ( + title, content, + region, ageRange, thumbnail, heart, + ) = createRefs() + + Text( + text = youthPolicy.title, + modifier.constrainAs( + title, + constrainBlock = { + top.linkTo(parent.top) + start.linkTo(parent.start) + end.linkTo(thumbnail.start, margin = 8.dp) + width = Dimension.fillToConstraints + }, + ), + color = WithpeaceTheme.colors.SystemBlack, + style = WithpeaceTheme.typography.homePolicyTitle, + overflow = TextOverflow.Ellipsis, + maxLines = 1, + ) + Text( + text = youthPolicy.content, + modifier = modifier + .constrainAs( + content, + constrainBlock = { + top.linkTo(title.bottom, margin = 8.dp) + start.linkTo(parent.start) + end.linkTo(thumbnail.start, margin = 8.dp) + width = Dimension.fillToConstraints + }, + ), + color = WithpeaceTheme.colors.SystemBlack, + style = WithpeaceTheme.typography.homePolicyContent, + overflow = TextOverflow.Ellipsis, + maxLines = 2, + ) + BookmarkButton( + isClicked = youthPolicy.isBookmarked, + modifier = modifier.constrainAs( + heart, + ) { + top.linkTo(content.bottom, margin = 8.dp) + start.linkTo(parent.start) + bottom.linkTo(parent.bottom) + }, + onClick = { isClicked -> + onBookmarkClick(youthPolicy.id, isClicked) + }, + ) + + Text( + text = youthPolicy.region.name, + color = WithpeaceTheme.colors.MainPurple, + modifier = modifier + .constrainAs( + region, + constrainBlock = { + top.linkTo(heart.top) + start.linkTo(heart.end, margin = 8.dp) + bottom.linkTo(heart.bottom) + }, + ) + .background( + color = WithpeaceTheme.colors.SubPurple, + shape = RoundedCornerShape(size = 5.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = WithpeaceTheme.typography.Tag, + ) + + Text( + text = youthPolicy.ageInfo, + color = WithpeaceTheme.colors.SystemGray1, + modifier = modifier + .constrainAs( + ageRange, + constrainBlock = { + top.linkTo(region.top) + start.linkTo(region.end, margin = 8.dp) + bottom.linkTo(region.bottom) + }, + ) + .background( + color = WithpeaceTheme.colors.SystemGray3, + shape = RoundedCornerShape(size = 5.dp), + ) + .padding(horizontal = 8.dp, vertical = 4.dp), + style = WithpeaceTheme.typography.Tag, + ) + + Image( + modifier = modifier + .size(57.dp) + .clip(RoundedCornerShape(10.dp)) + .constrainAs( + ref = thumbnail, + constrainBlock = { + start.linkTo(title.end) + end.linkTo(parent.end) + top.linkTo(parent.top) + }, + ), + painter = painterResource(id = youthPolicy.classification.drawableResId), + contentDescription = stringResource(R.string.policy_classification_image), + ) + } + } +} + +@Composable +@Preview(showBackground = true) +fun PolicyListPreview() { + WithpeaceTheme { + PolicyListScreen( + youthPolicies = + flowOf( + PagingData.from( + listOf( + YouthPolicyUiModel( + id = "deterruisset", + title = "epicurei", + content = "interdum", + region = RegionUiModel.대구, + ageInfo = "quo", + classification = ClassificationUiModel.JOB, + isBookmarked = false, + ), + ), + ), + ).collectAsLazyPagingItems(), + selectedFilterUiState = PolicyFiltersUiModel(), + onDismissRequest = { }, + onClassificationCheckChanged = {}, + onRegionCheckChanged = {}, + onFilterAllOff = {}, + onSearchWithFilter = {}, + onCloseFilter = {}, + onPolicyClick = {}, + onBookmarkClick = { id, isChecked -> }, + ) + } +} diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListViewModel.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListViewModel.kt new file mode 100644 index 00000000..12bec2da --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/PolicyListViewModel.kt @@ -0,0 +1,168 @@ +package com.withpeace.withpeace.feature.policylist + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.map +import com.withpeace.withpeace.core.domain.extension.groupBy +import com.withpeace.withpeace.core.domain.model.error.ResponseError +import com.withpeace.withpeace.core.domain.model.policy.BookmarkInfo +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.domain.usecase.BookmarkPolicyUseCase +import com.withpeace.withpeace.core.domain.usecase.GetYouthPoliciesUseCase +import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel +import com.withpeace.withpeace.core.ui.policy.RegionUiModel +import com.withpeace.withpeace.core.ui.policy.toDomain +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.toDomain +import com.withpeace.withpeace.core.ui.policy.filtersetting.toUiModel +import com.withpeace.withpeace.feature.policylist.uistate.PolicyListUiEvent +import com.withpeace.withpeace.feature.policylist.uistate.YouthPolicyUiModel +import com.withpeace.withpeace.feature.policylist.uistate.toUiModel +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapMerge +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) +@HiltViewModel +class PolicyListViewModel @Inject constructor( + private val bookmarkPolicyUseCase: BookmarkPolicyUseCase, + private val youthPoliciesUseCase: GetYouthPoliciesUseCase, +) : ViewModel() { + private val bookmarkStateFlow = + MutableStateFlow(mapOf()) // paging 처리를 위한 북마크 여부 상태 홀더 + + private val _youthPolicyPagingFlow = MutableStateFlow(PagingData.empty()) + val youthPolicyPagingFlow = + combine( + _youthPolicyPagingFlow.asStateFlow(), + bookmarkStateFlow, + ) { youthPolicyPagingFlow, bookmarkFlow -> + youthPolicyPagingFlow.map { + lastByWhetherSuccessOfBookmarks[it.id] = it.isBookmarked + val bookmarkState = bookmarkFlow[it.id] + it.copy(isBookmarked = bookmarkState ?: it.isBookmarked) + } + }.cachedIn(viewModelScope) + + private val _selectingFilters = MutableStateFlow(PolicyFilters()) + val selectingFilters: StateFlow = + _selectingFilters.map { it.toUiModel() }.stateIn( + scope = viewModelScope, + SharingStarted.WhileSubscribed(), + PolicyFiltersUiModel(), + ) + + private val _uiEvent = Channel() + val uiEvent = _uiEvent.receiveAsFlow() + + private val debounceFlow = MutableSharedFlow(replay = 1) + + private val lastByWhetherSuccessOfBookmarks = + mutableMapOf() // optimistic UI에서 실패시에 사용할 캐시 데이터 + + private var completedFilters = PolicyFilters() + + init { + viewModelScope.launch { + debounceFlow.groupBy { it.id }.flatMapMerge { + it.second.debounce(300L) + }.collectLatest { bookmarkInfo -> // policyBookmarkViewModel과 다른 이유를 찾아보기 + bookmarkPolicyUseCase( + bookmarkInfo.id, bookmarkInfo.isBookmarked, + onError = { + bookmarkStateFlow.update { + it + mapOf( + bookmarkInfo.id to (lastByWhetherSuccessOfBookmarks[bookmarkInfo.id] + ?: !bookmarkInfo.isBookmarked), + ) + } + _uiEvent.send(PolicyListUiEvent.BookmarkFailure) + }, + ).collect { result -> + lastByWhetherSuccessOfBookmarks[result.id] = result.isBookmarked + if (result.isBookmarked) { + _uiEvent.send(PolicyListUiEvent.BookmarkSuccess) + } else { + _uiEvent.send(PolicyListUiEvent.UnBookmarkSuccess) + } + } + + } + } + fetchData() + } + + + private fun fetchData() { + viewModelScope.launch { + _youthPolicyPagingFlow.update { + youthPoliciesUseCase( + filterInfo = completedFilters, + onError = { + when (it) { + ResponseError.EXPIRED_TOKEN_ERROR -> {} + else -> {} + } + }, + ).map { + it.map { youthPolicy -> + youthPolicy.toUiModel() + } + }.cachedIn(viewModelScope).firstOrNull() ?: PagingData.empty() + } + } + } + + fun onCheckClassification(classification: ClassificationUiModel) { + _selectingFilters.update { + it.updateClassification(classification.toDomain()) + } + } + + fun onCheckRegion(region: RegionUiModel) { + _selectingFilters.update { + it.updateRegion(region.toDomain()) + } + } + + fun onCompleteFilter() { + completedFilters = selectingFilters.value.toDomain() + fetchData() + } + + fun onCancelFilter() { + _selectingFilters.update { completedFilters } + } + + fun onFilterAllOff() { + _selectingFilters.update { + it.removeAll() + } + } + + fun bookmark(id: String, isChecked: Boolean) { + bookmarkStateFlow.update { it + mapOf(id to isChecked) } + viewModelScope.launch { + debounceFlow.emit(BookmarkInfo(id, isChecked)) + } + } +} \ No newline at end of file diff --git a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/FilterBottomSheet.kt similarity index 51% rename from feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt rename to feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/FilterBottomSheet.kt index 3f02e149..7b45d93f 100644 --- a/feature/home/src/main/java/com/withpeace/withpeace/feature/home/filtersetting/FilterBottomSheet.kt +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/FilterBottomSheet.kt @@ -1,8 +1,6 @@ -package com.withpeace.withpeace.feature.home.filtersetting +package com.withpeace.withpeace.feature.policylist.filtersetting -import androidx.compose.animation.animateContentSize -import androidx.compose.animation.core.Spring -import androidx.compose.animation.core.spring +import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.Image import androidx.compose.foundation.ScrollState import androidx.compose.foundation.background @@ -10,6 +8,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -18,11 +18,10 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.wrapContentHeight +import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.ButtonColors -import androidx.compose.material3.Checkbox -import androidx.compose.material3.CheckboxDefaults import androidx.compose.material3.HorizontalDivider import androidx.compose.material3.Text import androidx.compose.material3.TextButton @@ -31,6 +30,7 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalDensity @@ -40,9 +40,8 @@ import androidx.compose.ui.unit.dp import com.withpeace.withpeace.core.designsystem.theme.WithpeaceTheme import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel import com.withpeace.withpeace.core.ui.policy.RegionUiModel -import com.withpeace.withpeace.feature.home.R -import com.withpeace.withpeace.feature.home.filtersetting.uistate.FilterListUiState -import com.withpeace.withpeace.feature.home.uistate.PolicyFiltersUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.feature.policylist.R @Composable fun FilterBottomSheet( @@ -56,7 +55,14 @@ fun FilterBottomSheet( onCloseFilter: () -> Unit, ) { val filterListUiState = - remember { mutableStateOf(FilterListUiState().getStateByFilterState(selectedFilterUiState)) } + remember { + mutableStateOf( + PolicyFiltersUiModel( + classifications = ClassificationUiModel.entries, + regions = RegionUiModel.entries, + ), + ) + } val configuration = LocalConfiguration.current val screenHeight = configuration.screenHeightDp.dp val footerHeight = remember { mutableStateOf(0.dp) } @@ -90,14 +96,6 @@ fun FilterBottomSheet( selectedFilterUiState = selectedFilterUiState, onClassificationCheckChanged = onClassificationCheckChanged, onRegionCheckChanged = onRegionCheckChanged, - onClassificationMoreViewClick = { - filterListUiState.value = - filterListUiState.value.copy(isClassificationExpanded = !filterListUiState.value.isClassificationExpanded) - }, - onRegionMoreViewClick = { - filterListUiState.value = - filterListUiState.value.copy(isRegionExpanded = !filterListUiState.value.isRegionExpanded) - }, scrollState = scrollState, ) } @@ -133,93 +131,116 @@ private fun FilterHeader(modifier: Modifier, onCloseFilter: () -> Unit) { ) } +@OptIn(ExperimentalLayoutApi::class) @Composable private fun ScrollableFilterSection( modifier: Modifier, - filterListUiState: FilterListUiState, + filterListUiState: PolicyFiltersUiModel, selectedFilterUiState: PolicyFiltersUiModel, onClassificationCheckChanged: (ClassificationUiModel) -> Unit, onRegionCheckChanged: (RegionUiModel) -> Unit, - onClassificationMoreViewClick: () -> Unit, - onRegionMoreViewClick: () -> Unit, scrollState: ScrollState, ) { - val scrollSectionHeight = remember { mutableStateOf(0.dp) } - val localDensity = LocalDensity.current - val columnModifier = modifier.padding(horizontal = 24.dp) - Column( - modifier = - if (scrollSectionHeight.value == 0.dp) columnModifier - .onSizeChanged { - if (!filterListUiState.isRegionExpanded && !filterListUiState.isClassificationExpanded) { - scrollSectionHeight.value = with(localDensity) { it.height.toDp() } - } - } - .verticalScroll(scrollState) - else columnModifier - .height(scrollSectionHeight.value) + modifier = modifier .verticalScroll(scrollState) + .padding(horizontal = 24.dp), ) { Spacer(modifier = modifier.height(16.dp)) Text( text = stringResource(R.string.policy_classfication), style = WithpeaceTheme.typography.title2, - color = WithpeaceTheme.colors.SystemBlack, + color = WithpeaceTheme.colors.SnackbarBlack, ) Spacer(modifier = modifier.height(16.dp)) - Column( - modifier = modifier.animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - ), - ) { - filterListUiState.getClassifications().forEach { - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = - Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = stringResource(id = it.stringResId), - style = WithpeaceTheme.typography.body, - color = WithpeaceTheme.colors.SystemBlack, - ) - Checkbox( - colors = CheckboxDefaults.colors( - checkedColor = WithpeaceTheme.colors.MainPurple, - uncheckedColor = WithpeaceTheme.colors.SystemGray2, - checkmarkColor = WithpeaceTheme.colors.SystemWhite, + LazyRow { + items(3) { + val filterItem = filterListUiState.classifications[it] + if (selectedFilterUiState.classifications.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, ), - checked = selectedFilterUiState.classifications.contains(it), - onCheckedChange = { _ -> onClassificationCheckChanged(it) }, - ) + onClick = { onClassificationCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + color = WithpeaceTheme.colors.SystemBlack, + ) + } } } } - - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = modifier - .fillMaxWidth() - .clickable { - onClassificationMoreViewClick() - }, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource(id = if (filterListUiState.isClassificationExpanded) R.string.filter_fold else R.string.filter_expanded), - color = WithpeaceTheme.colors.SystemGray1, - style = WithpeaceTheme.typography.caption, - modifier = modifier.padding(end = 4.dp), - ) - Image( - painterResource(id = if (filterListUiState.isClassificationExpanded) R.drawable.ic_filter_fold else R.drawable.ic_filter_expanded), - contentDescription = stringResource(id = R.string.filter_expanded), - ) + Spacer(modifier = modifier.height(8.dp)) + LazyRow { + items(2) { + val filterItem = filterListUiState.classifications[it + 3] + if (selectedFilterUiState.classifications.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onClassificationCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = stringResource(id = filterItem.stringResId), + color = WithpeaceTheme.colors.SystemBlack, + ) + } + } + } } Spacer(modifier = modifier.height(16.dp)) HorizontalDivider( @@ -230,66 +251,54 @@ private fun ScrollableFilterSection( Text( text = stringResource(id = R.string.region), style = WithpeaceTheme.typography.title2, - color = WithpeaceTheme.colors.SystemBlack, + color = WithpeaceTheme.colors.SnackbarBlack, ) Spacer(modifier = modifier.height(16.dp)) - Column( - modifier = modifier.animateContentSize( - animationSpec = spring( - dampingRatio = Spring.DampingRatioNoBouncy, - stiffness = Spring.StiffnessMedium, - ), - ), - ) { - filterListUiState.getRegions().forEach { filterItem -> - Row( - modifier = modifier.fillMaxWidth(), - horizontalArrangement = - Arrangement.SpaceBetween, - verticalAlignment = Alignment.CenterVertically, - ) { - Text( - text = filterItem.name, - style = WithpeaceTheme.typography.body, - color = WithpeaceTheme.colors.SystemBlack, - ) - Checkbox( - colors = CheckboxDefaults.colors( - checkedColor = WithpeaceTheme.colors.MainPurple, - uncheckedColor = WithpeaceTheme.colors.SystemGray2, - checkmarkColor = WithpeaceTheme.colors.SystemWhite, + FlowRow { + filterListUiState.regions.dropLast(1).forEach { filterItem -> + if (selectedFilterUiState.regions.contains(filterItem)) { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.MainPurple, + contentColor = WithpeaceTheme.colors.SystemWhite, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemWhite, ), - checked = selectedFilterUiState.regions.contains(filterItem), - onCheckedChange = { onRegionCheckChanged(filterItem) }, - ) + onClick = { onRegionCheckChanged(filterItem) }, + modifier = modifier + .padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = filterItem.name, + ) + } + } else { + TextButton( + colors = ButtonColors( + containerColor = WithpeaceTheme.colors.SystemWhite, + contentColor = WithpeaceTheme.colors.SystemBlack, + disabledContainerColor = Color.Transparent, + disabledContentColor = WithpeaceTheme.colors.SystemBlack, + ), + onClick = { onRegionCheckChanged(filterItem) }, + border = BorderStroke( + width = 1.dp, + color = WithpeaceTheme.colors.SystemGray2, + ), + modifier = modifier.padding(end = 8.dp), + ) { + Text( + style = WithpeaceTheme.typography.body, + text = filterItem.name, + color = WithpeaceTheme.colors.SystemBlack, + ) + } } } } - Spacer(modifier = Modifier.height(16.dp)) - Row( - modifier = modifier - .fillMaxWidth() - .clickable { - onRegionMoreViewClick() - }, - horizontalArrangement = Arrangement.Center, - ) { - Text( - text = stringResource( - if (filterListUiState.isRegionExpanded) R.string.filter_fold else R.string.filter_expanded, - ), - color = WithpeaceTheme.colors.SystemGray1, - style = WithpeaceTheme.typography.caption, - modifier = modifier.padding(end = 4.dp), - ) - Image( - painterResource(id = if (filterListUiState.isRegionExpanded) R.drawable.ic_filter_fold else R.drawable.ic_filter_expanded), - contentDescription = stringResource(id = R.string.filter_expanded), - ) - } - Spacer(modifier = Modifier.height(24.dp)) } } diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/PolicyFiltersUiModel.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/PolicyFiltersUiModel.kt new file mode 100644 index 00000000..d79b7c29 --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/filtersetting/PolicyFiltersUiModel.kt @@ -0,0 +1,29 @@ +package com.withpeace.withpeace.feature.policylist.filtersetting + +import com.withpeace.withpeace.core.domain.model.policy.PolicyFilters +import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel +import com.withpeace.withpeace.core.ui.policy.RegionUiModel +import com.withpeace.withpeace.core.ui.policy.filtersetting.PolicyFiltersUiModel +import com.withpeace.withpeace.core.ui.policy.toDomain +import com.withpeace.withpeace.core.ui.policy.toUiModel + +data class PolicyFiltersUiModel( + val classifications: List = listOf(), + val regions: List = listOf(), +) + +fun PolicyFilters.toUiModel(): PolicyFiltersUiModel { + return PolicyFiltersUiModel( + regions = regions.map { it.toUiModel() }, + classifications = classifications.map { + it.toUiModel() + }, + ) +} + +fun PolicyFiltersUiModel.toDomain(): PolicyFilters { + return PolicyFilters( + regions = regions.map { it.toDomain() }, + classifications = classifications.map { it.toDomain() }, + ) +} \ No newline at end of file diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/navigation/PolicyListNavigation.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/navigation/PolicyListNavigation.kt new file mode 100644 index 00000000..c11a2121 --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/navigation/PolicyListNavigation.kt @@ -0,0 +1,32 @@ +package com.withpeace.withpeace.feature.policylist.navigation + +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.navigation.NavController +import androidx.navigation.NavGraphBuilder +import androidx.navigation.NavOptions +import androidx.navigation.compose.composable +import com.withpeace.withpeace.feature.policylist.PolicyListRoute + +const val POLICY_LIST_ROUTE = "policy_list_route" + +fun NavController.navigateToPolicyList(navOptions: NavOptions? = null) = + navigate(POLICY_LIST_ROUTE, navOptions) + +fun NavGraphBuilder.policyListGraph( + onShowSnackBar: (message: String) -> Unit, + onNavigationSnackBar: (message: String) -> Unit = {}, + onPolicyClick: (String) -> Unit, +) { + composable( + route = POLICY_LIST_ROUTE, + enterTransition = { EnterTransition.None }, + exitTransition = { ExitTransition.None }, + ) { + PolicyListRoute( + onNavigationSnackBar = onNavigationSnackBar, + onShowSnackBar = onShowSnackBar, + onPolicyClick = onPolicyClick, + ) + } +} diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/PolicyListUiEvent.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/PolicyListUiEvent.kt new file mode 100644 index 00000000..7f4282bf --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/PolicyListUiEvent.kt @@ -0,0 +1,7 @@ +package com.withpeace.withpeace.feature.policylist.uistate + +sealed interface PolicyListUiEvent { + data object BookmarkSuccess : PolicyListUiEvent + data object BookmarkFailure : PolicyListUiEvent + data object UnBookmarkSuccess : PolicyListUiEvent +} \ No newline at end of file diff --git a/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/YouthPolicyUiModel.kt b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/YouthPolicyUiModel.kt new file mode 100644 index 00000000..05de0328 --- /dev/null +++ b/feature/policylist/src/main/java/com/withpeace/withpeace/feature/policylist/uistate/YouthPolicyUiModel.kt @@ -0,0 +1,43 @@ +package com.withpeace.withpeace.feature.policylist.uistate + +import com.withpeace.withpeace.core.domain.model.policy.YouthPolicy +import com.withpeace.withpeace.core.ui.policy.ClassificationUiModel +import com.withpeace.withpeace.core.ui.policy.RegionUiModel +import com.withpeace.withpeace.core.ui.policy.toDomain +import com.withpeace.withpeace.core.ui.policy.toUiModel + +data class YouthPolicyUiModel( + val id: String, + val title: String, + val content: String, + val region: RegionUiModel, + val ageInfo: String, + val classification: ClassificationUiModel, + val isBookmarked: Boolean, +) + +fun YouthPolicy.toUiModel(): YouthPolicyUiModel { + return YouthPolicyUiModel( + id = id, + title = title, + content = introduce, + region = region.toUiModel(), + ageInfo = ageInfo, + classification = policyClassification.toUiModel(), + isBookmarked = isBookmarked, + ) +} + +fun YouthPolicyUiModel.toDomain(): YouthPolicy { + return YouthPolicy( + id = id, + title = title, + introduce = content, + region = region.toDomain(), + policyClassification = classification.toDomain(), + ageInfo = ageInfo, + isBookmarked = isBookmarked, + ) +} + +// 오류처리(Response 값 확인)+ 에러 상황 처리 logout(home 포함) \ No newline at end of file diff --git a/feature/policylist/src/main/res/drawable/ic_filter.xml b/feature/policylist/src/main/res/drawable/ic_filter.xml new file mode 100644 index 00000000..e8ad2a36 --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_filter.xml @@ -0,0 +1,12 @@ + + + diff --git a/feature/policylist/src/main/res/drawable/ic_filter_close.xml b/feature/policylist/src/main/res/drawable/ic_filter_close.xml new file mode 100644 index 00000000..3b9176a8 --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_filter_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/feature/policylist/src/main/res/drawable/ic_filter_expanded.xml b/feature/policylist/src/main/res/drawable/ic_filter_expanded.xml new file mode 100644 index 00000000..00b563cf --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_filter_expanded.xml @@ -0,0 +1,12 @@ + + + diff --git a/feature/policylist/src/main/res/drawable/ic_filter_fold.xml b/feature/policylist/src/main/res/drawable/ic_filter_fold.xml new file mode 100644 index 00000000..fd1a600d --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_filter_fold.xml @@ -0,0 +1,12 @@ + + + diff --git a/feature/policylist/src/main/res/drawable/ic_home_logo.xml b/feature/policylist/src/main/res/drawable/ic_home_logo.xml new file mode 100644 index 00000000..cc68f430 --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_home_logo.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/feature/policylist/src/main/res/drawable/ic_home_thumbnail_example.png b/feature/policylist/src/main/res/drawable/ic_home_thumbnail_example.png new file mode 100644 index 00000000..bac4677c Binary files /dev/null and b/feature/policylist/src/main/res/drawable/ic_home_thumbnail_example.png differ diff --git a/feature/policylist/src/main/res/drawable/ic_no_result.xml b/feature/policylist/src/main/res/drawable/ic_no_result.xml new file mode 100644 index 00000000..9770e928 --- /dev/null +++ b/feature/policylist/src/main/res/drawable/ic_no_result.xml @@ -0,0 +1,21 @@ + + + + + diff --git a/feature/policylist/src/main/res/values/strings.xml b/feature/policylist/src/main/res/values/strings.xml new file mode 100644 index 00000000..7ef98b14 --- /dev/null +++ b/feature/policylist/src/main/res/values/strings.xml @@ -0,0 +1,14 @@ + + + 청하 로고 + 필터 + 필터 닫기 + 더보기 + 정책분야 + 지역 + 전체 해제 + 검색하기 + 접기 + 정책 분류 이미지 + 검색 결과 없음 + \ No newline at end of file diff --git a/feature/policylist/src/test/java/com/withpeace/withpeace/feature/policylist/ExampleUnitTest.kt b/feature/policylist/src/test/java/com/withpeace/withpeace/feature/policylist/ExampleUnitTest.kt new file mode 100644 index 00000000..046cecf1 --- /dev/null +++ b/feature/policylist/src/test/java/com/withpeace/withpeace/feature/policylist/ExampleUnitTest.kt @@ -0,0 +1,17 @@ +package com.withpeace.withpeace.feature.policylist + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * See [testing documentation](http://d.android.com/tools/testing). + */ +class ExampleUnitTest { + @Test + fun addition_isCorrect() { + assertEquals(4, 2 + 2) + } +} \ No newline at end of file diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt index f012cb99..2818edc8 100644 --- a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt +++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListScreen.kt @@ -18,8 +18,10 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Text import androidx.compose.material3.VerticalDivider import androidx.compose.runtime.Composable @@ -67,6 +69,7 @@ fun PostListRoute( navigateToDetail: (postId: Long) -> Unit, onShowSnackBar: (String) -> Unit, onAuthExpired: () -> Unit, + onClickRegisterPost: () -> Unit = {}, ) { val postListPagingData = viewModel.postListPagingFlow.collectAsLazyPagingItems() val currentTopic by viewModel.currentTopic.collectAsStateWithLifecycle() @@ -75,6 +78,7 @@ fun PostListRoute( postListPagingData = postListPagingData, onTopicChanged = viewModel::onTopicChanged, navigateToDetail = navigateToDetail, + onClickRegisterPost = onClickRegisterPost, ) LaunchedEffect(null) { @@ -93,8 +97,13 @@ fun PostListScreen( postListPagingData: LazyPagingItems, onTopicChanged: (PostTopicUiModel) -> Unit = {}, navigateToDetail: (postId: Long) -> Unit = {}, + onClickRegisterPost: () -> Unit = {}, ) { - Column(modifier = Modifier.fillMaxSize().background(WithpeaceTheme.colors.SystemWhite)) { + Column( + modifier = Modifier + .fillMaxSize() + .background(WithpeaceTheme.colors.SystemWhite), + ) { Spacer(modifier = Modifier.height(8.dp)) TopicTabs( currentTopic = currentTopic, @@ -128,6 +137,24 @@ fun PostListScreen( } } } + Box(modifier = Modifier.fillMaxSize()) { + FloatingActionButton( + shape = CircleShape, + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 16.dp, end = 16.dp), + onClick = { + onClickRegisterPost() + }, + containerColor = WithpeaceTheme.colors.MainPurple, + ) { + Image( + painter = painterResource(id = com.withpeace.withpeace.feature.postlist.R.drawable.ic_post_plus), + contentDescription = "글 작성" + ) + } + } + TrackScreenViewEvent(screenName = "post_list") } diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt index d85fd858..25f61017 100644 --- a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt +++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/PostListViewModel.kt @@ -1,6 +1,5 @@ package com.withpeace.withpeace.feature.postlist -import android.util.Log import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope @@ -14,17 +13,15 @@ import com.withpeace.withpeace.core.ui.post.PostTopicUiModel import com.withpeace.withpeace.core.ui.post.PostUiModel import com.withpeace.withpeace.core.ui.post.toDomain import com.withpeace.withpeace.core.ui.post.toPostUiModel -import com.withpeace.withpeace.feature.postlist.navigation.POST_LIST_DELETED_POST_ID_ARGUMENT +import com.withpeace.withpeace.feature.postlist.navigation.POST_TYPE_ARGUMENT import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.receiveAsFlow -import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import javax.inject.Inject @@ -32,6 +29,7 @@ import javax.inject.Inject @HiltViewModel class PostListViewModel @Inject constructor( private val getPostsUseCase: GetPostsUseCase, + savedStateHandle: SavedStateHandle, ) : ViewModel() { private val _uiEvent = Channel() val uiEvent = _uiEvent.receiveAsFlow() diff --git a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt index 2502843c..328ec835 100644 --- a/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt +++ b/feature/postlist/src/main/java/com/withpeace/withpeace/feature/postlist/navigation/PostListNavigation.kt @@ -1,33 +1,56 @@ package com.withpeace.withpeace.feature.postlist.navigation +import android.util.Log import androidx.compose.animation.EnterTransition import androidx.compose.animation.ExitTransition import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import androidx.navigation.NavGraphBuilder import androidx.navigation.NavOptions +import androidx.navigation.NavType import androidx.navigation.compose.composable +import androidx.navigation.navArgument +import com.withpeace.withpeace.core.ui.post.PostTopicUiModel import com.withpeace.withpeace.feature.postlist.PostListRoute import com.withpeace.withpeace.feature.postlist.PostListViewModel const val POST_LIST_ROUTE = "post_list_route" const val POST_LIST_DELETED_POST_ID_ARGUMENT = "post_list_deleted_post_id" -fun NavController.navigateToPostList(navOptions: NavOptions? = null) = - navigate(POST_LIST_ROUTE, navOptions) +const val POST_TYPE_ARGUMENT = "youthPolicy_argument" +const val POST_LIST_ROUTE_WITH_ARGUMENT = + "$POST_LIST_ROUTE/{$POST_TYPE_ARGUMENT}" + +fun NavController.navigateToPostList(postTopic: String? = null, navOptions: NavOptions? = null) = + navigate("$POST_LIST_ROUTE/${postTopic ?: PostTopicUiModel.FREEDOM}", navOptions) fun NavGraphBuilder.postListGraph( onShowSnackBar: (String) -> Unit, navigateToPostDetail: (postId: Long) -> Unit, onAuthExpired: () -> Unit, + onClickRegisterPost: () -> Unit = {}, ) { composable( - route = POST_LIST_ROUTE, + arguments = listOf( + navArgument(POST_TYPE_ARGUMENT) { + type = NavType.StringType + }, + ), + route = POST_LIST_ROUTE_WITH_ARGUMENT, enterTransition = { EnterTransition.None }, exitTransition = { ExitTransition.None }, ) { - val deletedId = it.savedStateHandle.get(POST_LIST_DELETED_POST_ID_ARGUMENT) val viewModel: PostListViewModel = hiltViewModel() + val deletedId = it.savedStateHandle.get(POST_LIST_DELETED_POST_ID_ARGUMENT) + + val postType = it.savedStateHandle.get(POST_TYPE_ARGUMENT) + // postType?.let { // recomposition이 안되는건가 + // viewModel.onTopicChanged( + // PostTopicUiModel.entries.find { + // it.name == postType + // } ?: PostTopicUiModel.FREEDOM, + // ) + // } deletedId?.let { id -> viewModel.updateDeletedId(id) } @@ -36,6 +59,7 @@ fun NavGraphBuilder.postListGraph( onShowSnackBar = onShowSnackBar, navigateToDetail = navigateToPostDetail, onAuthExpired = onAuthExpired, + onClickRegisterPost = onClickRegisterPost, ) } } diff --git a/feature/postlist/src/main/res/drawable/ic_post_plus.xml b/feature/postlist/src/main/res/drawable/ic_post_plus.xml new file mode 100644 index 00000000..eadaeeec --- /dev/null +++ b/feature/postlist/src/main/res/drawable/ic_post_plus.xml @@ -0,0 +1,18 @@ + + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86282654..2d63f4bd 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ tikxml = "0.8.13" landscapist = "2.2.13" sandwich = "1.3.7" composeShimmer = "1.0.5" +balloon = "1.6.7" junit4 = "4.13.2" junitVintageEngine = "5.10.0" @@ -131,6 +132,7 @@ skydoves-landscapist-bom = { group = "com.github.skydoves", name = "landscapist- skydoves-landscapist-coil = { group = "com.github.skydoves", name = "landscapist-coil", version.ref = "landscapist" } skydoves-landscapist-glide = { group = "com.github.skydoves", name = "landscapist-glide", version.ref = "landscapist" } skydoves-landscapist-placeholder = { group = "com.github.skydoves", name = "landscapist-placeholder", version.ref = "landscapist" } +skydoves-balloon = { group = "com.github.skydoves", name = "balloon-compose", version.ref = "balloon" } landscapist-animation = { group = "com.github.skydoves", name = "landscapist-animation" } compose-shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version.ref = "composeShimmer" } skydoves-sandwich = { group = "com.github.skydoves", name = "sandwich", version.ref = "sandwich" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 75a93bdc..417d22b7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -44,3 +44,4 @@ include(":feature:termsofservice") include(":benchmark") include(":feature:policybookmarks") include(":feature:disablepolicy") +include(":feature:policylist")