diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 48e1fc35..98534168 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { implementation(project(":feature:search")) implementation(project(":feature:settings")) implementation(project(":feature:home")) + implementation(project(":feature:linklist")) // hilt implementation(libs.hilt) diff --git a/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt b/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt index d2214709..0b21712a 100644 --- a/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt +++ b/app/src/main/java/pokitmons/pokit/navigation/RootDestination.kt @@ -33,7 +33,9 @@ object AddLink { val route: String = "addLink" val linkIdArg = "link_id" val linkUrl = "link_url" - val routeWithArgs = "$route?$linkIdArg={$linkIdArg}&$linkUrl={$linkUrl}" + val pokitId = "pokit_id" + val pokitName = "pokit_name" + val routeWithArgs = "$route?$linkIdArg={$linkIdArg}&$linkUrl={$linkUrl}&$pokitId={$pokitId}&$pokitName={$pokitName}" var arguments = listOf( navArgument(linkIdArg) { nullable = true @@ -42,6 +44,14 @@ object AddLink { navArgument(linkUrl) { nullable = true type = NavType.StringType + }, + navArgument(pokitId) { + nullable = true + type = NavType.StringType + }, + navArgument(pokitName) { + nullable = true + type = NavType.StringType } ) } @@ -87,3 +97,12 @@ object EditNickname { object Alarm { val route: String = "alarm" } + +object LinkList { + val route: String = "linklist" + val linkListTypeArg = "type" + val routeWithArgs = "$route/{$linkListTypeArg}" + var arguments = listOf( + navArgument(linkListTypeArg) { defaultValue = "bookmark" } + ) +} diff --git a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt index 439d88df..2fe0adc3 100644 --- a/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt +++ b/app/src/main/java/pokitmons/pokit/navigation/RootNavHost.kt @@ -20,6 +20,8 @@ import pokitmons.pokit.alarm.AlarmViewModel import pokitmons.pokit.home.HomeScreen import pokitmons.pokit.home.pokit.PokitViewModel import pokitmons.pokit.keyword.KeywordScreen +import pokitmons.pokit.linklist.LinkListScreenContainer +import pokitmons.pokit.linklist.LinkListViewModel import pokitmons.pokit.login.LoginScreen import pokitmons.pokit.nickname.InputNicknameScreen import pokitmons.pokit.search.SearchScreenContainer @@ -129,6 +131,9 @@ fun RootNavHost( }, onNavigateToPokitModify = { pokitId -> navHostController.navigate("${AddPokit.route}?${AddPokit.pokitIdArg}=$pokitId") + }, + onNavigateToAddLink = { pokitId, pokitName -> + navHostController.navigate("${AddLink.route}?${AddLink.pokitId}=$pokitId&${AddLink.pokitName}=$pokitName") } ) } @@ -185,7 +190,9 @@ fun RootNavHost( onNavigateAddPokit = { navHostController.navigate(AddPokit.route) }, onNavigateToLinkModify = { navHostController.navigate("${AddLink.route}?${AddLink.linkIdArg}=$it") }, onNavigateToPokitModify = { navHostController.navigate("${AddPokit.route}?${AddPokit.pokitIdArg}=$it") }, - onNavigateToAlarm = { navHostController.navigate(Alarm.route) } + onNavigateToAlarm = { navHostController.navigate(Alarm.route) }, + onNavigateToUnreadLinkList = { navHostController.navigate("${LinkList.route}/unread") }, + onNavigateToBookmarkLinkList = { navHostController.navigate("${LinkList.route}/bookmark") } ) } @@ -199,5 +206,19 @@ fun RootNavHost( } ) } + + composable( + route = LinkList.routeWithArgs, + arguments = LinkList.arguments + ) { + val viewModel: LinkListViewModel = hiltViewModel() + LinkListScreenContainer( + viewModel = viewModel, + onBackPressed = navHostController::popBackStack, + onNavigateToLinkModify = { linkId -> + navHostController.navigate("${AddLink.route}?${AddLink.linkIdArg}=$linkId") + } + ) + } } } diff --git a/core/feature/build.gradle.kts b/core/feature/build.gradle.kts index bccf77b1..0f0da843 100644 --- a/core/feature/build.gradle.kts +++ b/core/feature/build.gradle.kts @@ -41,6 +41,13 @@ android { composeOptions { kotlinCompilerExtensionVersion = "1.5.1" } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } } dependencies { diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/LinkUrlCard.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/LinkUrlCard.kt index 2e610797..a00dd0cc 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/LinkUrlCard.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/LinkUrlCard.kt @@ -2,6 +2,7 @@ package pokitmons.pokit.core.ui.components.block.linkurlcard import androidx.compose.foundation.Image import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.IntrinsicSize import androidx.compose.foundation.layout.Row @@ -21,7 +22,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.unit.dp import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.core.ui.utils.conditional import pokitmons.pokit.core.ui.utils.noRippleClickable +import pokitmons.pokit.core.ui.utils.shimmerEffect @Composable fun LinkUrlCard( @@ -30,6 +33,7 @@ fun LinkUrlCard( url: String, title: String, openWebBrowserByClick: Boolean, + isLoading: Boolean = false, ) { val uriHandler = LocalUriHandler.current @@ -39,7 +43,7 @@ fun LinkUrlCard( .clip(RoundedCornerShape(12.dp)) .height(IntrinsicSize.Min) .noRippleClickable { - if (openWebBrowserByClick) { + if (openWebBrowserByClick && !isLoading) { uriHandler.openUri(url) } } @@ -49,12 +53,16 @@ fun LinkUrlCard( shape = RoundedCornerShape(12.dp) ) ) { - Image( - painter = thumbnailPainter, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.width(124.dp).fillMaxHeight() - ) + if (isLoading) { + Box(modifier = Modifier.width(124.dp).fillMaxHeight().shimmerEffect()) + } else { + Image( + painter = thumbnailPainter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.width(124.dp).fillMaxHeight() + ) + } Column( modifier = Modifier @@ -62,7 +70,7 @@ fun LinkUrlCard( .weight(1f) ) { Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().conditional(isLoading) { shimmerEffect() }, text = title, maxLines = 2, style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.textSecondary) @@ -71,7 +79,7 @@ fun LinkUrlCard( Spacer(modifier = Modifier.height(8.dp)) Text( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth().conditional(isLoading) { shimmerEffect() }, text = url, maxLines = 2, style = PokitTheme.typography.detail2.copy(color = PokitTheme.colors.textTertiary) diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/Preview.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/Preview.kt index ebb1a9a6..b5e377c8 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/Preview.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/linkurlcard/Preview.kt @@ -36,6 +36,15 @@ private fun LinkUrlCardPreview() { title = "네이버", openWebBrowserByClick = false ) + + LinkUrlCard( + modifier = Modifier.padding(20.dp), + thumbnailPainter = painterResource(id = R.drawable.icon_24_google), + url = "", + title = "", + openWebBrowserByClick = false, + isLoading = true + ) } } } diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/PokitToast.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/PokitToast.kt index 550a4009..5aa309a6 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/PokitToast.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/PokitToast.kt @@ -19,6 +19,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import pokitmons.pokit.core.ui.R +import pokitmons.pokit.core.ui.components.block.pokittoast.attributes.PokitToastType import pokitmons.pokit.core.ui.theme.PokitTheme @Composable @@ -27,11 +28,12 @@ fun PokitToast( text: String, onClick: (() -> Unit)? = null, onClickClose: () -> Unit = {}, + type: PokitToastType = PokitToastType.Normal, ) { Row( modifier = modifier .clip(RoundedCornerShape(9999.dp)) - .background(PokitTheme.colors.backgroundTertiary) + .background(type.color) .clickable( enabled = onClick != null, onClick = onClick ?: {} @@ -39,13 +41,25 @@ fun PokitToast( .padding(start = 20.dp, end = 14.dp, top = 12.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically ) { - Text( - text = text, - style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.inverseWh), - maxLines = 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + Row( + modifier = Modifier.weight(1f), + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = text, + style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.inverseWh), + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + type.iconResourceId?.let { resourceId -> + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = resourceId), + contentDescription = null, + tint = PokitTheme.colors.inverseWh + ) + } + } Spacer(modifier = Modifier.width(12.dp)) diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/Preview.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/Preview.kt index 663d3435..42b6b088 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/Preview.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/Preview.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.components.block.pokittoast.attributes.PokitToastType import pokitmons.pokit.core.ui.theme.PokitTheme @Preview(showBackground = true) @@ -23,6 +24,24 @@ private fun Preview() { text = "최대 30개의 포킷을 생성할 수 있습니다.\n포킷을 삭제한 뒤에 추가해주세요.", onClick = {} ) + PokitToast( + modifier = Modifier.padding(20.dp), + text = "링크 저장 완료", + onClick = {}, + type = PokitToastType.Success + ) + PokitToast( + modifier = Modifier.padding(20.dp), + text = "링크 저장 실패", + onClick = {}, + type = PokitToastType.Error + ) + PokitToast( + modifier = Modifier.padding(20.dp), + text = "저장 공간 부족", + onClick = {}, + type = PokitToastType.Warning + ) } } } diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/attributes/PokitToastType.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/attributes/PokitToastType.kt new file mode 100644 index 00000000..aec5adc6 --- /dev/null +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/pokittoast/attributes/PokitToastType.kt @@ -0,0 +1,19 @@ +package pokitmons.pokit.core.ui.components.block.pokittoast.attributes + +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import pokitmons.pokit.core.ui.R +import pokitmons.pokit.core.ui.theme.PokitTheme + +enum class PokitToastType( + private val getColor: @Composable () -> Color, + val iconResourceId: Int? = null, +) { + Normal(getColor = { PokitTheme.colors.backgroundTertiary }), + Success(getColor = { PokitTheme.colors.success }, iconResourceId = R.drawable.icon_24_check), + Error(getColor = { PokitTheme.colors.error }), + Warning(getColor = { PokitTheme.colors.warning }), + ; + + val color: Color @Composable get() = getColor() +} diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/toolbar/Toolbar.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/toolbar/Toolbar.kt new file mode 100644 index 00000000..f53db240 --- /dev/null +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/block/toolbar/Toolbar.kt @@ -0,0 +1,54 @@ +package pokitmons.pokit.core.ui.components.block.toolbar + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +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.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.R +import pokitmons.pokit.core.ui.theme.PokitTheme + +@Composable +fun Toolbar( + onClickBack: () -> Unit, + title: String, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .height(56.dp) + .padding(horizontal = 12.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.Center + ) { + IconButton( + modifier = Modifier.size(48.dp), + onClick = onClickBack + ) { + Icon( + painter = painterResource(id = R.drawable.icon_24_arrow_left), + contentDescription = "back button" + ) + } + + Text( + modifier = Modifier.weight(1f), + text = title, + style = PokitTheme.typography.title3.copy(color = PokitTheme.colors.textPrimary), + textAlign = TextAlign.Center + ) + + Spacer(modifier = Modifier.width(48.dp)) + } +} diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/EmptyPooki.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/EmptyPooki.kt index 67d7f820..9d5eb8b2 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/EmptyPooki.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/EmptyPooki.kt @@ -1,15 +1,20 @@ package pokitmons.pokit.core.ui.components.template.pookiempty import androidx.compose.foundation.Image +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import pokitmons.pokit.core.ui.R @@ -20,6 +25,7 @@ fun EmptyPooki( modifier: Modifier = Modifier, title: String, sub: String, + button: EmptyPookiButton? = null, ) { Box( modifier = modifier, @@ -42,6 +48,25 @@ fun EmptyPooki( Spacer(modifier = Modifier.height(8.dp)) Text(text = sub, style = PokitTheme.typography.body2Medium.copy(color = PokitTheme.colors.textSecondary)) + + button?.let { buttonInfo -> + Spacer(modifier = Modifier.height(20.dp)) + + Text( + modifier = Modifier.clip(shape = RoundedCornerShape(8.dp)) + .clickable { buttonInfo.onClick() } + .border( + shape = RoundedCornerShape(8.dp), + width = 1.dp, + color = PokitTheme.colors.borderSecondary + ) + .padding(vertical = 10.dp, horizontal = 16.dp), + text = buttonInfo.text, + style = PokitTheme.typography.label2Regular.copy(color = PokitTheme.colors.textPrimary) + ) + } } } } + +data class EmptyPookiButton(val text: String, val onClick: () -> Unit) diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/Preview.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/Preview.kt index 175f345b..b8c421c5 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/Preview.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/components/template/pookiempty/Preview.kt @@ -12,7 +12,11 @@ import pokitmons.pokit.core.ui.theme.PokitTheme private fun Preview() { PokitTheme { Surface(modifier = Modifier.fillMaxSize()) { - EmptyPooki(title = "저장된 포킷이 없어요!", sub = "포킷을 생성해 링크를 저장해보세요") + EmptyPooki( + title = "저장된 포킷이 없어요!", + sub = "포킷을 생성해 링크를 저장해보세요", + button = EmptyPookiButton("포킷 추가하기", {}) + ) } } } diff --git a/core/ui/src/main/java/pokitmons/pokit/core/ui/utils/ModifierUtils.kt b/core/ui/src/main/java/pokitmons/pokit/core/ui/utils/ModifierUtils.kt index b390060c..a5e1f4be 100644 --- a/core/ui/src/main/java/pokitmons/pokit/core/ui/utils/ModifierUtils.kt +++ b/core/ui/src/main/java/pokitmons/pokit/core/ui/utils/ModifierUtils.kt @@ -1,10 +1,25 @@ package pokitmons.pokit.core.ui.utils +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.unit.IntSize +import pokitmons.pokit.core.ui.theme.color.Gray300 +import pokitmons.pokit.core.ui.theme.color.Gray500 internal fun Modifier.conditional(condition: Boolean, modifier: Modifier.() -> Modifier): Modifier { return if (condition) { @@ -22,3 +37,30 @@ fun Modifier.noRippleClickable(onClick: () -> Unit): Modifier { onClick = onClick ) } + +internal fun Modifier.shimmerEffect(): Modifier = composed { + var size by remember { + mutableStateOf(IntSize.Zero) + } + val transition = rememberInfiniteTransition(label = "infiniteTransition") + val startOffsetX by transition.animateFloat( + initialValue = -2 * size.width.toFloat(), + targetValue = 2 * size.width.toFloat(), + animationSpec = infiniteRepeatable(animation = tween(1000)), + label = "infiniteTransitionStartOffsetX" + ) + + background( + brush = Brush.linearGradient( + colors = listOf( + Gray300, + Gray500, + Gray300 + ), + start = Offset(startOffsetX, 0f), + end = Offset(startOffsetX + size.width.toFloat(), size.height.toFloat()) + ) + ).onGloballyPositioned { + size = it.size + } +} diff --git a/data/build.gradle.kts b/data/build.gradle.kts index 05066de1..c6c51fc5 100644 --- a/data/build.gradle.kts +++ b/data/build.gradle.kts @@ -37,6 +37,13 @@ android { kotlinOptions { jvmTarget = "1.8" } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } } dependencies { diff --git a/data/src/main/java/pokitmons/pokit/data/api/RemindApi.kt b/data/src/main/java/pokitmons/pokit/data/api/RemindApi.kt index b912b7f8..22c69d81 100644 --- a/data/src/main/java/pokitmons/pokit/data/api/RemindApi.kt +++ b/data/src/main/java/pokitmons/pokit/data/api/RemindApi.kt @@ -1,7 +1,9 @@ package pokitmons.pokit.data.api +import pokitmons.pokit.data.model.home.remind.BookmarkContentCountResponse import pokitmons.pokit.data.model.home.remind.Remind import pokitmons.pokit.data.model.home.remind.RemindResponse +import pokitmons.pokit.data.model.home.remind.UnreadContentCountResponse import pokitmons.pokit.domain.model.pokit.PokitsSort import retrofit2.http.GET import retrofit2.http.Query @@ -14,6 +16,9 @@ interface RemindApi { @Query("sort") sort: String = PokitsSort.RECENT.value, ): RemindResponse + @GET("remind/unread/count") + suspend fun getUnreadContentsCount(): UnreadContentCountResponse + @GET("remind/today") suspend fun getTodayContents( @Query("size") size: Int = 10, @@ -27,4 +32,7 @@ interface RemindApi { @Query("page") page: Int = 0, @Query("sort") sort: String = PokitsSort.RECENT.value, ): RemindResponse + + @GET("remind/bookmark/count") + suspend fun getBookmarkContentsCount(): BookmarkContentCountResponse } diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSource.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSource.kt index dbf1ee25..0a0340db 100644 --- a/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSource.kt +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSource.kt @@ -1,11 +1,15 @@ package pokitmons.pokit.data.datasource.remote.home.remind +import pokitmons.pokit.data.model.home.remind.BookmarkContentCountResponse import pokitmons.pokit.data.model.home.remind.Remind import pokitmons.pokit.data.model.home.remind.RemindRequest import pokitmons.pokit.data.model.home.remind.RemindResponse +import pokitmons.pokit.data.model.home.remind.UnreadContentCountResponse interface RemindDataSource { suspend fun getUnreadContents(remindRequest: RemindRequest): RemindResponse + suspend fun getUnreadContentsCount(): UnreadContentCountResponse suspend fun getTodayContents(remindRequest: RemindRequest): List suspend fun getBookmarkContents(remindRequest: RemindRequest): RemindResponse + suspend fun getBookmarkContentsCount(): BookmarkContentCountResponse } diff --git a/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSourceImpl.kt b/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSourceImpl.kt index 0b88996f..45e511be 100644 --- a/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSourceImpl.kt +++ b/data/src/main/java/pokitmons/pokit/data/datasource/remote/home/remind/RemindDataSourceImpl.kt @@ -1,14 +1,20 @@ package pokitmons.pokit.data.datasource.remote.home.remind import pokitmons.pokit.data.api.RemindApi +import pokitmons.pokit.data.model.home.remind.BookmarkContentCountResponse import pokitmons.pokit.data.model.home.remind.Remind import pokitmons.pokit.data.model.home.remind.RemindRequest import pokitmons.pokit.data.model.home.remind.RemindResponse +import pokitmons.pokit.data.model.home.remind.UnreadContentCountResponse import javax.inject.Inject class RemindDataSourceImpl @Inject constructor(private val remindApi: RemindApi) : RemindDataSource { override suspend fun getUnreadContents(remindRequest: RemindRequest): RemindResponse { - return remindApi.getUnreadContents() + return remindApi.getUnreadContents(size = remindRequest.size, page = remindRequest.page, sort = remindRequest.sort.value) + } + + override suspend fun getUnreadContentsCount(): UnreadContentCountResponse { + return remindApi.getUnreadContentsCount() } override suspend fun getTodayContents(remindRequest: RemindRequest): List { @@ -16,6 +22,10 @@ class RemindDataSourceImpl @Inject constructor(private val remindApi: RemindApi) } override suspend fun getBookmarkContents(remindRequest: RemindRequest): RemindResponse { - return remindApi.getBookmarkContents() + return remindApi.getBookmarkContents(size = remindRequest.size, page = remindRequest.page, sort = remindRequest.sort.value) + } + + override suspend fun getBookmarkContentsCount(): BookmarkContentCountResponse { + return remindApi.getBookmarkContentsCount() } } diff --git a/data/src/main/java/pokitmons/pokit/data/model/home/remind/RemindResponse.kt b/data/src/main/java/pokitmons/pokit/data/model/home/remind/RemindResponse.kt index d2789fbf..fe4aaccf 100644 --- a/data/src/main/java/pokitmons/pokit/data/model/home/remind/RemindResponse.kt +++ b/data/src/main/java/pokitmons/pokit/data/model/home/remind/RemindResponse.kt @@ -10,3 +10,13 @@ data class RemindResponse( val size: Int = 10, val sort: List = emptyList(), ) + +@Serializable +data class BookmarkContentCountResponse( + val bookmarkContentCount: Int = 0, +) + +@Serializable +data class UnreadContentCountResponse( + val unreadContentCount: Int = 0, +) diff --git a/data/src/main/java/pokitmons/pokit/data/repository/home/remind/RemindRepositoryImpl.kt b/data/src/main/java/pokitmons/pokit/data/repository/home/remind/RemindRepositoryImpl.kt index 5cf6ddce..9a2e56b1 100644 --- a/data/src/main/java/pokitmons/pokit/data/repository/home/remind/RemindRepositoryImpl.kt +++ b/data/src/main/java/pokitmons/pokit/data/repository/home/remind/RemindRepositoryImpl.kt @@ -18,7 +18,9 @@ class RemindRepositoryImpl @Inject constructor(private val remindDataSource: Rem sort: PokitsSort, ): PokitResult> { return runCatching { - val response = remindDataSource.getUnreadContents(RemindRequest()) + val response = remindDataSource.getUnreadContents( + RemindRequest(size = size, page = page, sort = sort) + ) val remindResponse = RemindMapper.mapperToRemind(response) PokitResult.Success(remindResponse) }.getOrElse { throwable -> @@ -26,6 +28,15 @@ class RemindRepositoryImpl @Inject constructor(private val remindDataSource: Rem } } + override suspend fun getUnReadContentsCount(): PokitResult { + return runCatching { + val response = remindDataSource.getUnreadContentsCount() + PokitResult.Success(response.unreadContentCount) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } + override suspend fun getTodayContents( filterUncategorized: Boolean, size: Int, @@ -48,11 +59,22 @@ class RemindRepositoryImpl @Inject constructor(private val remindDataSource: Rem sort: PokitsSort, ): PokitResult> { return runCatching { - val response = remindDataSource.getBookmarkContents(RemindRequest()) + val response = remindDataSource.getBookmarkContents( + RemindRequest(size = size, page = page, sort = sort) + ) val remindResponse = RemindMapper.mapperToRemind(response) PokitResult.Success(remindResponse) }.getOrElse { throwable -> parseErrorResult(throwable) } } + + override suspend fun getBookmarkContentsCount(): PokitResult { + return runCatching { + val response = remindDataSource.getBookmarkContentsCount() + PokitResult.Success(response.bookmarkContentCount) + }.getOrElse { throwable -> + parseErrorResult(throwable) + } + } } diff --git a/domain/src/main/java/pokitmons/pokit/domain/repository/home/remind/RemindRepository.kt b/domain/src/main/java/pokitmons/pokit/domain/repository/home/remind/RemindRepository.kt index d86b89f9..bfe6f728 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/repository/home/remind/RemindRepository.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/repository/home/remind/RemindRepository.kt @@ -12,6 +12,8 @@ interface RemindRepository { sort: PokitsSort = PokitsSort.RECENT, ): PokitResult> + suspend fun getUnReadContentsCount(): PokitResult + suspend fun getTodayContents( filterUncategorized: Boolean = true, size: Int = 10, @@ -25,4 +27,6 @@ interface RemindRepository { page: Int = 0, sort: PokitsSort = PokitsSort.RECENT, ): PokitResult> + + suspend fun getBookmarkContentsCount(): PokitResult } diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsCountUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsCountUseCase.kt new file mode 100644 index 00000000..42cc6fba --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsCountUseCase.kt @@ -0,0 +1,11 @@ +package pokitmons.pokit.domain.usecase.home.remind + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.home.remind.RemindRepository +import javax.inject.Inject + +class BookMarkContentsCountUseCase @Inject constructor(private val remindRepository: RemindRepository) { + suspend fun getCount(): PokitResult { + return remindRepository.getBookmarkContentsCount() + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsUseCase.kt index 4d589dc8..64d5c092 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsUseCase.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/BookMarkContentsUseCase.kt @@ -2,11 +2,12 @@ package pokitmons.pokit.domain.usecase.home.remind import pokitmons.pokit.domain.commom.PokitResult import pokitmons.pokit.domain.model.home.remind.RemindResult +import pokitmons.pokit.domain.model.pokit.PokitsSort import pokitmons.pokit.domain.repository.home.remind.RemindRepository import javax.inject.Inject class BookMarkContentsUseCase @Inject constructor(private val remindRepository: RemindRepository) { - suspend fun getBookmarkContents(): PokitResult> { - return remindRepository.getBookmarkContents() + suspend fun getBookmarkContents(sort: PokitsSort = PokitsSort.RECENT): PokitResult> { + return remindRepository.getBookmarkContents(sort = sort) } } diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsCountUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsCountUseCase.kt new file mode 100644 index 00000000..8c9b7363 --- /dev/null +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsCountUseCase.kt @@ -0,0 +1,11 @@ +package pokitmons.pokit.domain.usecase.home.remind + +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.repository.home.remind.RemindRepository +import javax.inject.Inject + +class UnReadContentsCountUseCase @Inject constructor(private val remindRepository: RemindRepository) { + suspend fun getCount(): PokitResult { + return remindRepository.getUnReadContentsCount() + } +} diff --git a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsUseCase.kt b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsUseCase.kt index 5b449479..8e3d17be 100644 --- a/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsUseCase.kt +++ b/domain/src/main/java/pokitmons/pokit/domain/usecase/home/remind/UnReadContentsUseCase.kt @@ -2,11 +2,12 @@ package pokitmons.pokit.domain.usecase.home.remind import pokitmons.pokit.domain.commom.PokitResult import pokitmons.pokit.domain.model.home.remind.RemindResult +import pokitmons.pokit.domain.model.pokit.PokitsSort import pokitmons.pokit.domain.repository.home.remind.RemindRepository import javax.inject.Inject class UnReadContentsUseCase @Inject constructor(private val remindRepository: RemindRepository) { - suspend fun getUnreadContents(): PokitResult> { - return remindRepository.getUnReadContents() + suspend fun getUnreadContents(sort: PokitsSort = PokitsSort.RECENT): PokitResult> { + return remindRepository.getUnReadContents(sort = sort) } } diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt index 731a551b..bfeaf753 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkScreen.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import com.strayalpaca.addlink.components.block.Link +import com.strayalpaca.addlink.components.block.LoadingLink import com.strayalpaca.addlink.components.block.Toolbar import com.strayalpaca.addlink.model.AddLinkScreenSideEffect import com.strayalpaca.addlink.model.AddLinkScreenState @@ -205,7 +206,10 @@ fun AddLinkScreen( ) { Spacer(modifier = Modifier.height(16.dp)) - if (state.link != null) { + if (state.step == ScreenStep.LINK_LOADING) { + LoadingLink() + Spacer(modifier = Modifier.height(16.dp)) + } else if (state.link != null) { Link(link = state.link, title = title.ifEmpty { null }) Spacer(modifier = Modifier.height(16.dp)) } diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt index fc031bdb..b7c11ab3 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/AddLinkViewModel.kt @@ -96,7 +96,18 @@ class AddLinkViewModel @Inject constructor( if (currentLinkId != null) { loadPokitLink(currentLinkId) } else { - loadUncategorizedPokit() + val initPokitId = savedStateHandle.get("pokit_id") + val initPokitName = savedStateHandle.get("pokit_name") + // pokit 상세에서 링크 추가를 누른 경우 (초기 포킷 설정) + if (initPokitName != null && initPokitId != null) { + intent { + reduce { + state.copy(currentPokit = Pokit(initPokitName, initPokitId, 0)) + } + } + } else { // 홈 화면에서 링크 추가를 누른 경우 + loadUncategorizedPokit() + } } copiedLinkUrl?.let { url -> diff --git a/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt index 23a336da..95429d2e 100644 --- a/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt +++ b/feature/addlink/src/main/java/com/strayalpaca/addlink/components/block/Link.kt @@ -1,29 +1,24 @@ package com.strayalpaca.addlink.components.block -import androidx.compose.foundation.border -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -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.padding -import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.clip -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp -import coil.compose.AsyncImage +import coil.compose.rememberAsyncImagePainter import com.strayalpaca.addlink.R import com.strayalpaca.addlink.model.Link -import pokitmons.pokit.core.ui.theme.PokitTheme -import pokitmons.pokit.core.ui.utils.noRippleClickable +import pokitmons.pokit.core.ui.components.block.linkurlcard.LinkUrlCard + +@Composable +internal fun LoadingLink() { + LinkUrlCard( + thumbnailPainter = rememberAsyncImagePainter(model = null), + url = "", + title = "", + openWebBrowserByClick = false, + isLoading = true + ) +} @Composable internal fun Link( @@ -32,53 +27,14 @@ internal fun Link( modifier: Modifier = Modifier, openWebBrowserByClick: Boolean = true, ) { - val uriHandler = LocalUriHandler.current val placeHolder = stringResource(id = R.string.placeholder_title) val linkTitle = remember(link, title) { title ?: link.title.ifEmpty { placeHolder } } - Row( - modifier = modifier - .fillMaxWidth() - .clip(RoundedCornerShape(12.dp)) - .noRippleClickable { - if (openWebBrowserByClick) { - uriHandler.openUri(link.url) - } - } - .height(IntrinsicSize.Min) - .border( - width = 1.dp, - color = PokitTheme.colors.borderTertiary, - shape = RoundedCornerShape(12.dp) - ) - ) { - AsyncImage( - model = link.imageUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.width(124.dp) - ) - - Column( - modifier = Modifier - .padding(start = 16.dp, end = 20.dp, top = 16.dp, bottom = 16.dp) - .weight(1f) - ) { - Text( - modifier = Modifier.fillMaxWidth(), - text = linkTitle, - maxLines = 2, - style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.textSecondary) - ) - - Spacer(modifier = Modifier.height(8.dp)) - - Text( - modifier = Modifier.fillMaxWidth(), - text = link.url, - maxLines = 2, - style = PokitTheme.typography.detail2.copy(color = PokitTheme.colors.textTertiary) - ) - } - } + LinkUrlCard( + modifier = modifier, + thumbnailPainter = rememberAsyncImagePainter(model = link.imageUrl), + url = link.url, + title = linkTitle, + openWebBrowserByClick = openWebBrowserByClick + ) } diff --git a/feature/home/src/main/java/pokitmons/pokit/home/HomeScreen.kt b/feature/home/src/main/java/pokitmons/pokit/home/HomeScreen.kt index a48fea1a..34c3b55a 100644 --- a/feature/home/src/main/java/pokitmons/pokit/home/HomeScreen.kt +++ b/feature/home/src/main/java/pokitmons/pokit/home/HomeScreen.kt @@ -57,6 +57,8 @@ fun HomeScreen( onNavigateToLinkModify: (String) -> Unit, onNavigateToPokitModify: (String) -> Unit, onNavigateToAlarm: () -> Unit, + onNavigateToBookmarkLinkList: () -> Unit, + onNavigateToUnreadLinkList: () -> Unit, ) { val sheetState = rememberModalBottomSheetState() val scope = rememberCoroutineScope() @@ -185,14 +187,18 @@ fun HomeScreen( modifier = Modifier.padding(padding), onNavigateToPokitDetail = onNavigateToPokitDetail, onNavigateToLinkModify = onNavigateToLinkModify, - onNavigateToPokitModify = onNavigateToPokitModify + onNavigateToPokitModify = onNavigateToPokitModify, + onNavigateToAddLink = { onNavigateAddLink(null) }, + onNavigateToAddPokit = onNavigateAddPokit ) } is ScreenType.Remind -> { RemindScreen( modifier = Modifier.padding(padding), - onNavigateToLinkModify = onNavigateToLinkModify + onNavigateToLinkModify = onNavigateToLinkModify, + onNavigateToUnreadLinkList = onNavigateToUnreadLinkList, + onNavigateToBookmarkLinkList = onNavigateToBookmarkLinkList ) } } diff --git a/feature/home/src/main/java/pokitmons/pokit/home/pokit/PokitScreen.kt b/feature/home/src/main/java/pokitmons/pokit/home/pokit/PokitScreen.kt index 9521da84..0502137a 100644 --- a/feature/home/src/main/java/pokitmons/pokit/home/pokit/PokitScreen.kt +++ b/feature/home/src/main/java/pokitmons/pokit/home/pokit/PokitScreen.kt @@ -33,6 +33,7 @@ import pokitmons.pokit.core.ui.components.block.pokitcard.PokitCard import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet import pokitmons.pokit.core.ui.components.template.modifybottomsheet.ModifyBottomSheetContent import pokitmons.pokit.core.ui.components.template.pookiempty.EmptyPooki +import pokitmons.pokit.core.ui.components.template.pookiempty.EmptyPookiButton import pokitmons.pokit.core.ui.components.template.pookierror.ErrorPooki import pokitmons.pokit.core.ui.components.template.removeItemBottomSheet.TwoButtonBottomSheetContent import pokitmons.pokit.core.ui.R.string as coreString @@ -45,6 +46,8 @@ fun PokitScreen( onNavigateToPokitDetail: (String, Int) -> Unit, onNavigateToLinkModify: (String) -> Unit, onNavigateToPokitModify: (String) -> Unit, + onNavigateToAddLink: () -> Unit, + onNavigateToAddPokit: () -> Unit, ) { val pokits = viewModel.pokits.collectAsState() val pokitsState by viewModel.pokitsState.collectAsState() @@ -157,7 +160,11 @@ fun PokitScreen( EmptyPooki( modifier = Modifier.fillMaxSize(), title = stringResource(id = coreString.title_empty_pokits), - sub = stringResource(id = coreString.sub_empty_pokits) + sub = stringResource(id = coreString.sub_empty_pokits), + button = EmptyPookiButton( + text = stringResource(id = homeString.title_add_pokit), + onClick = onNavigateToAddPokit + ) ) } @@ -207,7 +214,11 @@ fun PokitScreen( EmptyPooki( modifier = Modifier.fillMaxSize(), title = stringResource(id = coreString.title_empty_links), - sub = stringResource(id = coreString.sub_empty_links) + sub = stringResource(id = coreString.sub_empty_links), + button = EmptyPookiButton( + text = stringResource(id = homeString.title_add_link), + onClick = onNavigateToAddLink + ) ) } diff --git a/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindScreen.kt b/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindScreen.kt index 6f92b8ed..3c9f74fd 100644 --- a/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindScreen.kt +++ b/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindScreen.kt @@ -44,6 +44,8 @@ fun RemindScreen( modifier: Modifier = Modifier, viewModel: RemindViewModel = hiltViewModel(), onNavigateToLinkModify: (String) -> Unit, + onNavigateToBookmarkLinkList: () -> Unit, + onNavigateToUnreadLinkList: () -> Unit, ) { val unreadContents = viewModel.unReadContents.collectAsState() val unreadContentsState by viewModel.unreadContentNetworkState.collectAsState() @@ -212,7 +214,10 @@ fun RemindScreen( Spacer(modifier = Modifier.height(32.dp)) if ((unreadContentsState == NetworkState.IDLE && unreadContents.value.isNotEmpty())) { - RemindSection(title = "한번도 읽지 않았어요") { + RemindSection( + title = "한번도 읽지 않았어요", + onClickButton = onNavigateToUnreadLinkList + ) { Spacer(modifier = Modifier.height(16.dp)) Column( modifier = Modifier, @@ -239,7 +244,10 @@ fun RemindScreen( Spacer(modifier = Modifier.height(32.dp)) } - RemindSection(title = "즐겨찾기 링크만 모았어요") { + RemindSection( + title = "즐겨찾기 링크만 모았어요", + onClickButton = onNavigateToBookmarkLinkList + ) { Spacer(modifier = Modifier.height(12.dp)) when (bookmarkContentState) { diff --git a/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindSection.kt b/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindSection.kt index bb353e83..c15a1c2c 100644 --- a/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindSection.kt +++ b/feature/home/src/main/java/pokitmons/pokit/home/remind/RemindSection.kt @@ -1,20 +1,53 @@ package pokitmons.pokit.home.remind +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.size +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import pokitmons.pokit.core.ui.R import pokitmons.pokit.core.ui.theme.PokitTheme @Composable fun RemindSection( title: String, + onClickButton: (() -> Unit)? = null, content: @Composable () -> Unit, ) { - Column { - Text( - text = title, - style = PokitTheme.typography.title2 - ) + Column( + modifier = Modifier.fillMaxWidth() + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = title, + style = PokitTheme.typography.title2 + ) + + onClickButton?.let { + IconButton( + modifier = Modifier + .size(28.dp), + onClick = it + ) { + Icon( + painter = painterResource(id = R.drawable.icon_24_arrow_right), + contentDescription = "close filter bottomSheet" + ) + } + } + } content() } } diff --git a/feature/home/src/main/res/values/strings.xml b/feature/home/src/main/res/values/strings.xml index d2fe25cb..c7c3f28d 100644 --- a/feature/home/src/main/res/values/strings.xml +++ b/feature/home/src/main/res/values/strings.xml @@ -4,6 +4,9 @@ 최대 30개의 포킷을 생성할 수 있습니다.\n포킷을 삭제한 뒤에 추가해주세요. 복사한 링크 저장하기\n%s + 포킷 추가하기 + 링크 추가하기 + 포킷을 공유받았어요! 소중한 링크들이 담긴 포킷을 Pokit\n앱에서 지금 바로 확인해보세요! 앱에서 보기 diff --git a/feature/linklist/.gitignore b/feature/linklist/.gitignore new file mode 100644 index 00000000..42afabfd --- /dev/null +++ b/feature/linklist/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/feature/linklist/build.gradle.kts b/feature/linklist/build.gradle.kts new file mode 100644 index 00000000..7b0b7624 --- /dev/null +++ b/feature/linklist/build.gradle.kts @@ -0,0 +1,79 @@ +plugins { + alias(libs.plugins.com.android.library) + alias(libs.plugins.org.jetbrains.kotlin.android) + alias(libs.plugins.hilt) + id("kotlin-kapt") +} + +android { + tasks.withType().configureEach { + useJUnitPlatform() + } + + namespace = "pokitmons.pokit.linklist" + compileSdk = 34 + + defaultConfig { + minSdk = 24 + + testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles("consumer-rules.pro") + } + + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro" + ) + } + } + buildFeatures { + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = "1.5.1" + } + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + + implementation(libs.androidx.core.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.activity.compose) + implementation(platform(libs.androidx.compose.bom)) + implementation(libs.androidx.ui) + implementation(libs.androidx.ui.graphics) + implementation(libs.androidx.ui.tooling.preview) + implementation(libs.androidx.material3) + testImplementation(libs.junit) + androidTestImplementation(libs.androidx.junit) + androidTestImplementation(libs.androidx.espresso.core) + androidTestImplementation(platform(libs.androidx.compose.bom)) + androidTestImplementation(libs.androidx.ui.test.junit4) + debugImplementation(libs.androidx.ui.tooling) + debugImplementation(libs.androidx.ui.test.manifest) + + implementation(libs.orbit.compose) + implementation(libs.orbit.core) + implementation(libs.orbit.viewmodel) + + // hilt + implementation(libs.hilt) + kapt(libs.hilt.compiler) + + // coil + implementation(libs.coil.compose) + + implementation(project(":core:ui")) + implementation(project(":core:feature")) + implementation(project(":domain")) +} diff --git a/feature/linklist/consumer-rules.pro b/feature/linklist/consumer-rules.pro new file mode 100644 index 00000000..e69de29b diff --git a/feature/linklist/proguard-rules.pro b/feature/linklist/proguard-rules.pro new file mode 100644 index 00000000..481bb434 --- /dev/null +++ b/feature/linklist/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/linklist/src/main/AndroidManifest.xml b/feature/linklist/src/main/AndroidManifest.xml new file mode 100644 index 00000000..44008a43 --- /dev/null +++ b/feature/linklist/src/main/AndroidManifest.xml @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListScreen.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListScreen.kt new file mode 100644 index 00000000..7ed4df86 --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListScreen.kt @@ -0,0 +1,259 @@ +package pokitmons.pokit.linklist + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +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.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.rememberAsyncImagePainter +import pokitmons.pokit.core.feature.model.paging.PagingState +import pokitmons.pokit.core.feature.utils.ShareUrlLink +import pokitmons.pokit.core.ui.components.atom.loading.LoadingProgress +import pokitmons.pokit.core.ui.components.block.linkcard.LinkCard +import pokitmons.pokit.core.ui.components.block.toolbar.Toolbar +import pokitmons.pokit.core.ui.components.template.bottomsheet.PokitBottomSheet +import pokitmons.pokit.core.ui.components.template.linkdetailbottomsheet.LinkDetailBottomSheet +import pokitmons.pokit.core.ui.components.template.pookiempty.EmptyPooki +import pokitmons.pokit.core.ui.components.template.pookierror.ErrorPooki +import pokitmons.pokit.core.ui.components.template.removeItemBottomSheet.TwoButtonBottomSheetContent +import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.core.ui.utils.noRippleClickable +import pokitmons.pokit.linklist.model.BottomSheetType +import pokitmons.pokit.linklist.model.Link +import pokitmons.pokit.linklist.model.LinkListScreenState +import pokitmons.pokit.core.ui.R.drawable as CoreDrawable +import pokitmons.pokit.core.ui.R.string as CoreString + +@Composable +fun LinkListScreenContainer( + viewModel: LinkListViewModel, + onBackPressed: () -> Unit, + onNavigateToLinkModify: (String) -> Unit, +) { + val state by viewModel.state.collectAsState() + val linkList by viewModel.linkList.collectAsState() + val linkListState by viewModel.linkListState.collectAsState() + + LinkListScreen( + state = state, + onBackPressed = onBackPressed, + loadNextLinkList = viewModel::loadNextLinks, + toggleSort = viewModel::toggleSort, + linkList = linkList, + linkListState = linkListState, + showLinkDetailBottomSheet = viewModel::showLinkDetailBottomSheet, + hideLinkDetailBottomSheet = viewModel::hideLinkDetailBottomSheet, + showCheckLinkRemoveBottomSheet = viewModel::showCheckLinkRemoveBottomSheet, + hideCheckLinkRemoveBottomSheet = viewModel::hideCheckLinkRemoveBottomSheet, + onClickLinkRemove = viewModel::removeLink, + onClickModifyLink = onNavigateToLinkModify, + onClickBookmark = viewModel::toggleBookmark + ) +} + +@Composable +fun LinkListScreen( + state: LinkListScreenState, + onBackPressed: () -> Unit, + linkList: List = emptyList(), + linkListState: PagingState = PagingState.IDLE, + loadNextLinkList: () -> Unit, + toggleSort: () -> Unit, + showLinkDetailBottomSheet: (Link) -> Unit, + hideLinkDetailBottomSheet: () -> Unit, + showCheckLinkRemoveBottomSheet: () -> Unit, + hideCheckLinkRemoveBottomSheet: () -> Unit, + onClickLinkRemove: () -> Unit, + onClickModifyLink: (String) -> Unit, + onClickBookmark: () -> Unit, +) { + Column( + modifier = Modifier + .fillMaxSize() + .background(PokitTheme.colors.backgroundBase) + ) { + Spacer(modifier = Modifier.height(8.dp)) + + Toolbar( + modifier = Modifier.fillMaxWidth(), + onClickBack = onBackPressed, + title = stringResource(id = state.type.resourceId) + ) + + Spacer(modifier = Modifier.height(12.dp)) + + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Text( + text = stringResource(id = R.string.format_link_count, state.count), + style = PokitTheme.typography.detail1.copy(color = PokitTheme.colors.textSecondary) + ) + Row( + modifier = Modifier + .noRippleClickable { toggleSort() } + .padding(vertical = 12.dp) + ) { + Icon( + modifier = Modifier.size(18.dp), + painter = painterResource(id = CoreDrawable.icon_24_align), + contentDescription = "change sort", + tint = PokitTheme.colors.iconPrimary + ) + Spacer(modifier = Modifier.width(2.dp)) + Text( + text = stringResource(id = state.sort.titleResourceId), + style = PokitTheme.typography.body3Medium.copy(color = PokitTheme.colors.textSecondary) + ) + } + } + + val linkLazyColumnListState = rememberLazyListState() + val startLinkPaging = remember { + derivedStateOf { + linkLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= linkLazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } + } + + LaunchedEffect(startLinkPaging.value) { + if (startLinkPaging.value && linkListState == PagingState.IDLE) { + loadNextLinkList() + } + } + + when { + (linkListState == PagingState.LOADING_INIT) -> { + LoadingProgress( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + (linkListState == PagingState.FAILURE_INIT) -> { + ErrorPooki( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + title = stringResource(id = CoreString.title_error), + sub = stringResource(id = CoreString.sub_error) + ) + } + (linkList.isEmpty()) -> { + EmptyPooki( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + title = stringResource(id = CoreString.title_empty_links), + sub = stringResource(id = CoreString.sub_empty_links) + ) + } + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = linkLazyColumnListState + ) { + items( + items = linkList + ) { link -> + LinkCard( + item = link, + title = link.title, + sub = "${link.dateString} · ${link.domainUrl}", + painter = rememberAsyncImagePainter(link.imageUrl), + notRead = !link.isRead, + badgeText = link.pokitName, + onClickKebab = showLinkDetailBottomSheet, + onClickItem = showLinkDetailBottomSheet, + modifier = Modifier.padding(20.dp) + ) + + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + thickness = 1.dp, + color = PokitTheme.colors.borderTertiary + ) + } + } + } + } + + val context: Context = LocalContext.current + val link = state.bottomSheetInfo?.link ?: Link() + + // 수정 필요 + // onHideBottomSheet 호출로 인해 후속 호출로 발생한 삭제 bottomSheet가 종료되고 있음 + LinkDetailBottomSheet( + title = link.title, + memo = link.memo, + url = link.url, + thumbnailPainter = rememberAsyncImagePainter(link.imageUrl), + bookmark = link.bookmark, + openWebBrowserByClick = true, + pokitName = link.pokitName, + dateString = link.dateString, + onHideBottomSheet = hideLinkDetailBottomSheet, + show = state.bottomSheetInfo?.type == BottomSheetType.DETAIL, + onClickShareLink = { + ShareUrlLink(context, link.url) + }, + onClickModifyLink = { + hideLinkDetailBottomSheet() + onClickModifyLink(link.id) + }, + onClickRemoveLink = { + showCheckLinkRemoveBottomSheet() + }, + onClickBookmark = onClickBookmark + ) + + PokitBottomSheet( + onHideBottomSheet = hideCheckLinkRemoveBottomSheet, + show = state.bottomSheetInfo?.type == BottomSheetType.CHECK_REMOVE + ) { + TwoButtonBottomSheetContent( + title = stringResource(id = R.string.title_remove_link), + subText = stringResource(id = R.string.sub_remove_link), + onClickLeftButton = hideCheckLinkRemoveBottomSheet, + onClickRightButton = remember { + { + onClickLinkRemove() + hideCheckLinkRemoveBottomSheet() + } + } + ) + } + } +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListViewModel.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListViewModel.kt new file mode 100644 index 00000000..40a8ff0c --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/LinkListViewModel.kt @@ -0,0 +1,211 @@ +package pokitmons.pokit.linklist + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pokitmons.pokit.core.feature.model.paging.PagingLoadResult +import pokitmons.pokit.core.feature.model.paging.PagingSource +import pokitmons.pokit.core.feature.model.paging.PagingState +import pokitmons.pokit.core.feature.model.paging.SimplePaging +import pokitmons.pokit.core.feature.navigation.args.LinkUpdateEvent +import pokitmons.pokit.domain.commom.PokitResult +import pokitmons.pokit.domain.model.home.remind.RemindResult +import pokitmons.pokit.domain.usecase.home.remind.BookMarkContentsCountUseCase +import pokitmons.pokit.domain.usecase.home.remind.BookMarkContentsUseCase +import pokitmons.pokit.domain.usecase.home.remind.UnReadContentsCountUseCase +import pokitmons.pokit.domain.usecase.home.remind.UnReadContentsUseCase +import pokitmons.pokit.domain.usecase.link.DeleteLinkUseCase +import pokitmons.pokit.domain.usecase.link.GetLinkUseCase +import pokitmons.pokit.domain.usecase.link.SetBookmarkUseCase +import pokitmons.pokit.linklist.model.BottomSheetInfo +import pokitmons.pokit.linklist.model.BottomSheetType +import pokitmons.pokit.linklist.model.Link +import pokitmons.pokit.linklist.model.LinkListScreenState +import pokitmons.pokit.linklist.model.LinkListScreenType +import pokitmons.pokit.linklist.model.LinkSort +import javax.inject.Inject + +@HiltViewModel +class LinkListViewModel @Inject constructor( + private val bookMarkContentsUseCase: BookMarkContentsUseCase, + private val bookMarkContentsCountUseCase: BookMarkContentsCountUseCase, + private val unReadContentsUseCase: UnReadContentsUseCase, + private val unReadContentsCountUseCase: UnReadContentsCountUseCase, + private val getLinkUseCase: GetLinkUseCase, + private val deleteLinkUseCase: DeleteLinkUseCase, + private val setBookmarkUseCase: SetBookmarkUseCase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val linkPagingSource = object : PagingSource { + override suspend fun load(pageIndex: Int, pageSize: Int): PagingLoadResult { + val sort = LinkSort.toPokitsSort(state.value.sort) + + val response: PokitResult> = if (state.value.type == LinkListScreenType.Bookmark) { + bookMarkContentsUseCase.getBookmarkContents(sort = sort) + } else { + unReadContentsUseCase.getUnreadContents(sort = sort) + } + return PagingLoadResult.fromPokitResult( + pokitResult = response, + mapper = { remindResults -> remindResults.map { Link.fromRemindResult(it) } } + ) + } + } + + private val type: LinkListScreenType = savedStateHandle.get("type")?.let { LinkListScreenType.getByKey(it) } ?: LinkListScreenType.Unread + private val _state = MutableStateFlow(LinkListScreenState(sort = LinkSort.RECENT, type = type, count = 0)) + val state = _state.asStateFlow() + + private fun initLinkRemoveEventDetector() { + viewModelScope.launch { + LinkUpdateEvent.removedLink.collectLatest { removedLinkId -> + val targetLink = linkPaging.pagingData.value.find { it.id == removedLinkId.toString() } ?: return@collectLatest + linkPaging.deleteItem(targetLink.id) + } + } + } + + private val linkPaging = SimplePaging( + pagingSource = linkPagingSource, + getKeyFromItem = { remindResult -> remindResult.id }, + coroutineScope = viewModelScope + ) + + val linkList: StateFlow> = linkPaging.pagingData + val linkListState: StateFlow = linkPaging.pagingState + + init { + viewModelScope.launch { + linkPaging.refresh() + } + + updateContentsCount() + initLinkRemoveEventDetector() + } + + fun toggleSort() { + val sort = LinkSort.toggle(state.value.sort) + viewModelScope.launch(Dispatchers.IO) { + _state.update { it.copy(sort = sort, count = 0) } + updateContentsCount() + linkPaging.refresh() + } + } + + private fun updateContentsCount() { + viewModelScope.launch(Dispatchers.IO) { + val response = if (state.value.type == LinkListScreenType.Bookmark) { + bookMarkContentsCountUseCase.getCount() + } else { + unReadContentsCountUseCase.getCount() + } + + if (response is PokitResult.Success) { + _state.update { it.copy(count = response.result) } + } + } + } + + fun loadNextLinks() { + viewModelScope.launch { + linkPaging.load() + } + } + + fun showLinkDetailBottomSheet(link: Link) { + _state.update { + it.copy(bottomSheetInfo = BottomSheetInfo(type = BottomSheetType.DETAIL, link = link)) + } + + viewModelScope.launch { + val response = getLinkUseCase.getLink(link.id.toInt()) + if (response is PokitResult.Success && + state.value.bottomSheetInfo?.link?.id == link.id && + state.value.bottomSheetInfo?.type == BottomSheetType.DETAIL + ) { + val responseLink = Link.fromDomainLink(response.result).copy(imageUrl = link.imageUrl, isRead = true) + _state.update { state -> + state.copy( + bottomSheetInfo = state.bottomSheetInfo?.copy(link = responseLink) + ) + } + } + + val isReadChangedLink = linkPaging.pagingData.value + .find { it.id == link.id } + ?.copy(isRead = true) ?: return@launch + + linkPaging.modifyItem(isReadChangedLink) + } + } + + fun hideLinkDetailBottomSheet() { + if (_state.value.bottomSheetInfo?.type != BottomSheetType.DETAIL) return + _state.update { + it.copy(bottomSheetInfo = null) + } + } + + fun showCheckLinkRemoveBottomSheet() { + val currentState = _state.value.copy() + + val isInvokedFromDetailBottomSheet = (currentState.bottomSheetInfo?.type == BottomSheetType.DETAIL) + if (!isInvokedFromDetailBottomSheet) return + + _state.update { + it.copy(bottomSheetInfo = it.bottomSheetInfo?.copy(type = BottomSheetType.CHECK_REMOVE)) + } + } + + fun hideCheckLinkRemoveBottomSheet() { + if (_state.value.bottomSheetInfo?.type != BottomSheetType.CHECK_REMOVE) return + _state.update { + it.copy(bottomSheetInfo = null) + } + } + + fun removeLink() { + state.value.bottomSheetInfo?.link?.let { link -> + val linkId = link.id.toInt() + viewModelScope.launch { + val response = deleteLinkUseCase.deleteLink(linkId) + if (response is PokitResult.Success) { + LinkUpdateEvent.removeSuccess(linkId) + } + } + } + } + + fun toggleBookmark() { + state.value.bottomSheetInfo?.link?.let { link -> + val newBookmarkState = !link.bookmark + val linkId = link.id.toIntOrNull() ?: return@let + + viewModelScope.launch { + val response = setBookmarkUseCase.setBookMarked(linkId, newBookmarkState) + if (response is PokitResult.Success) { + val bookmarkChangedLink = linkPaging.pagingData.value + .find { it.id == link.id } + ?.copy(bookmark = newBookmarkState) ?: return@launch + linkPaging.modifyItem(bookmarkChangedLink) + + if (link.id == state.value.bottomSheetInfo?.link?.id) { + _state.update { state -> + state.copy( + bottomSheetInfo = state.bottomSheetInfo?.copy(link = bookmarkChangedLink) + ) + } + } + } + } + } + } +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/Preview.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/Preview.kt new file mode 100644 index 00000000..17408215 --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/Preview.kt @@ -0,0 +1,33 @@ +package pokitmons.pokit.linklist + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.tooling.preview.Preview +import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.linklist.model.LinkListScreenState + +@Preview(showBackground = true) +@Composable +fun Preview() { + PokitTheme { + Column( + modifier = Modifier.fillMaxSize() + ) { + LinkListScreen( + state = LinkListScreenState(), + onBackPressed = { }, + loadNextLinkList = { }, + toggleSort = {}, + showLinkDetailBottomSheet = {}, + hideLinkDetailBottomSheet = {}, + showCheckLinkRemoveBottomSheet = {}, + hideCheckLinkRemoveBottomSheet = {}, + onClickBookmark = {}, + onClickModifyLink = {}, + onClickLinkRemove = {} + ) + } + } +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/Link.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/Link.kt new file mode 100644 index 00000000..f32d73b5 --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/Link.kt @@ -0,0 +1,50 @@ +package pokitmons.pokit.linklist.model + +import pokitmons.pokit.domain.model.home.remind.RemindResult + +data class Link( + val id: String = "", + val title: String = "", + val dateString: String = "", + val domainUrl: String = "", + val isRead: Boolean = false, + val pokitName: String = "", + val pokitId: String = "", + val url: String = "", + val memo: String = "", + val bookmark: Boolean = false, + val imageUrl: String? = null, + val createdAt: String = "", +) { + companion object { + fun fromRemindResult(remindResult: RemindResult): Link { + return Link( + id = remindResult.id.toString(), + title = remindResult.title, + dateString = remindResult.createdAt, + domainUrl = remindResult.domain, + url = remindResult.data, + imageUrl = remindResult.thumbNail, + bookmark = true, + isRead = remindResult.isRead + ) + } + + fun fromDomainLink(domainLink: pokitmons.pokit.domain.model.link.Link): Link { + return Link( + id = domainLink.id.toString(), + title = domainLink.title, + dateString = domainLink.createdAt, + domainUrl = domainLink.domain, + isRead = domainLink.isRead, + url = domainLink.data, + memo = domainLink.memo, + imageUrl = domainLink.thumbnail, + createdAt = domainLink.createdAt, + pokitName = domainLink.categoryName, + pokitId = domainLink.categoryId.toString(), + bookmark = domainLink.favorites + ) + } + } +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenState.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenState.kt new file mode 100644 index 00000000..5c7066fc --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenState.kt @@ -0,0 +1,17 @@ +package pokitmons.pokit.linklist.model + +data class LinkListScreenState( + val type: LinkListScreenType = LinkListScreenType.Bookmark, + val sort: LinkSort = LinkSort.RECENT, + val count: Int = 0, + val bottomSheetInfo: BottomSheetInfo? = null, +) + +data class BottomSheetInfo( + val type: BottomSheetType, + val link: Link, +) + +enum class BottomSheetType { + DETAIL, CHECK_REMOVE +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenType.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenType.kt new file mode 100644 index 00000000..2047050a --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkListScreenType.kt @@ -0,0 +1,13 @@ +package pokitmons.pokit.linklist.model + +import pokitmons.pokit.linklist.R + +enum class LinkListScreenType(val resourceId: Int, val key: String) { + Unread(R.string.title_unread, "unread"), Bookmark(R.string.title_bookmark, "bookmark"); + + companion object { + fun getByKey(key: String): LinkListScreenType { + return LinkListScreenType.entries.find { it.key == key } ?: Unread + } + } +} diff --git a/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkSort.kt b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkSort.kt new file mode 100644 index 00000000..5c993ffa --- /dev/null +++ b/feature/linklist/src/main/java/pokitmons/pokit/linklist/model/LinkSort.kt @@ -0,0 +1,23 @@ +package pokitmons.pokit.linklist.model + +import pokitmons.pokit.domain.model.pokit.PokitsSort +import pokitmons.pokit.linklist.R + +enum class LinkSort(val titleResourceId: Int) { + RECENT(titleResourceId = R.string.title_sort_recent), + ALPHABETICAL(titleResourceId = R.string.title_sort_alphabet), + ; + + companion object { + fun toggle(sort: LinkSort): LinkSort { + return if (sort == RECENT) ALPHABETICAL else RECENT + } + + fun toPokitsSort(linkSort: LinkSort): PokitsSort { + return when (linkSort) { + RECENT -> PokitsSort.RECENT + ALPHABETICAL -> PokitsSort.ALPHABETICAL + } + } + } +} diff --git a/feature/linklist/src/main/res/values/strings.xml b/feature/linklist/src/main/res/values/strings.xml new file mode 100644 index 00000000..5bdca003 --- /dev/null +++ b/feature/linklist/src/main/res/values/strings.xml @@ -0,0 +1,13 @@ + + + 안읽음 + 즐겨찾기 + + 최신순 + 이름순 + + 링크 %d개 + + 링크를 정말 삭제하시겠습니까? + 함께 저장한 모든 정보가 삭제되며,\n복구하실 수 없습니다. + \ No newline at end of file diff --git a/feature/pokitdetail/build.gradle.kts b/feature/pokitdetail/build.gradle.kts index 965a79f8..8f12facf 100644 --- a/feature/pokitdetail/build.gradle.kts +++ b/feature/pokitdetail/build.gradle.kts @@ -42,6 +42,13 @@ android { kotlinOptions { jvmTarget = "1.8" } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + excludes += "META-INF/LICENSE.md" + excludes += "META-INF/LICENSE-notice.md" + } + } } dependencies { diff --git a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailScreen.kt b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailScreen.kt index 9de63c34..22920ee9 100644 --- a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailScreen.kt +++ b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailScreen.kt @@ -1,15 +1,21 @@ package com.strayalpaca.pokitdetail import android.content.Context +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column 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.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.HorizontalDivider import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -17,8 +23,12 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import coil.compose.rememberAsyncImagePainter @@ -44,6 +54,7 @@ import pokitmons.pokit.core.ui.components.template.pookiempty.EmptyPooki import pokitmons.pokit.core.ui.components.template.pookierror.ErrorPooki import pokitmons.pokit.core.ui.components.template.removeItemBottomSheet.TwoButtonBottomSheetContent import pokitmons.pokit.core.ui.theme.PokitTheme +import pokitmons.pokit.core.ui.R.drawable as coreDrawable import pokitmons.pokit.core.ui.R.string as coreString @Composable @@ -52,6 +63,7 @@ fun PokitDetailScreenContainer( onBackPressed: () -> Unit, onNavigateToLinkModify: (String) -> Unit, onNavigateToPokitModify: (String) -> Unit, + onNavigateToAddLink: (String, String) -> Unit, ) { val state by viewModel.state.collectAsState() val linkList by viewModel.linkList.collectAsState() @@ -97,7 +109,8 @@ fun PokitDetailScreenContainer( loadNextPokits = viewModel::loadNextPokits, refreshPokits = viewModel::refreshPokits, loadNextLinks = viewModel::loadNextLinks, - onClickBookmark = viewModel::toggleBookmark + onClickBookmark = viewModel::toggleBookmark, + onClickAddLink = onNavigateToAddLink ) } @@ -132,100 +145,123 @@ fun PokitDetailScreen( refreshPokits: () -> Unit = {}, loadNextLinks: () -> Unit = {}, onClickBookmark: () -> Unit = {}, + onClickAddLink: (String, String) -> Unit = { _, _ -> }, ) { - Column( + Box( modifier = Modifier.fillMaxSize() ) { - Spacer(modifier = Modifier.height(8.dp)) + Column( + modifier = Modifier.fillMaxSize() + ) { + Spacer(modifier = Modifier.height(8.dp)) - Toolbar( - onBackPressed = onBackPressed, - onClickKebab = showPokitModifyBottomSheet - ) + Toolbar( + onBackPressed = onBackPressed, + onClickKebab = showPokitModifyBottomSheet + ) - Spacer(modifier = Modifier.height(12.dp)) + Spacer(modifier = Modifier.height(12.dp)) - TitleArea( - title = state.currentPokit?.title ?: "", - sub = stringResource(id = pokitmons.pokit.core.ui.R.string.pokit_count_format, state.currentPokit?.count ?: 0), - onClickSelectPokit = showPokitSelectBottomSheet, - onClickSelectFilter = onClickFilter - ) + TitleArea( + title = state.currentPokit?.title ?: "", + sub = stringResource(id = pokitmons.pokit.core.ui.R.string.pokit_count_format, state.currentPokit?.count ?: 0), + onClickSelectPokit = showPokitSelectBottomSheet, + onClickSelectFilter = onClickFilter + ) - val linkLazyColumnListState = rememberLazyListState() - val startLinkPaging = remember { - derivedStateOf { - linkLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> - last.index >= linkLazyColumnListState.layoutInfo.totalItemsCount - 3 - } ?: false + val linkLazyColumnListState = rememberLazyListState() + val startLinkPaging = remember { + derivedStateOf { + linkLazyColumnListState.layoutInfo.visibleItemsInfo.lastOrNull()?.let { last -> + last.index >= linkLazyColumnListState.layoutInfo.totalItemsCount - 3 + } ?: false + } } - } - LaunchedEffect(startLinkPaging.value) { - if (startLinkPaging.value && linkListState == PagingState.IDLE) { - loadNextLinks() + LaunchedEffect(startLinkPaging.value) { + if (startLinkPaging.value && linkListState == PagingState.IDLE) { + loadNextLinks() + } } - } - when { - (linkListState == PagingState.LOADING_INIT) -> { - LoadingProgress( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - ) - } - (linkListState == PagingState.FAILURE_INIT) -> { - ErrorPooki( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - title = stringResource(id = coreString.title_error), - sub = stringResource(id = coreString.sub_error) - ) - } - (linkList.isEmpty()) -> { - EmptyPooki( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - title = stringResource(id = coreString.title_empty_links), - sub = stringResource(id = coreString.sub_empty_links) - ) - } - else -> { - LazyColumn( - modifier = Modifier - .fillMaxWidth() - .weight(1f), - state = linkLazyColumnListState - ) { - items( - items = linkList, - key = { it.id } - ) { link -> - LinkCard( - item = link, - title = link.title, - sub = "${link.dateString} · ${link.domainUrl}", - painter = rememberAsyncImagePainter(link.imageUrl), - notRead = !link.isRead, - badgeText = link.pokitName, - onClickKebab = showLinkModifyBottomSheet, - onClickItem = onClickLink, - modifier = Modifier.padding(20.dp) - ) + when { + (linkListState == PagingState.LOADING_INIT) -> { + LoadingProgress( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + ) + } + (linkListState == PagingState.FAILURE_INIT) -> { + ErrorPooki( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + title = stringResource(id = coreString.title_error), + sub = stringResource(id = coreString.sub_error) + ) + } + (linkList.isEmpty()) -> { + EmptyPooki( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + title = stringResource(id = coreString.title_empty_links), + sub = stringResource(id = coreString.sub_empty_links) + ) + } + else -> { + LazyColumn( + modifier = Modifier + .fillMaxWidth() + .weight(1f), + state = linkLazyColumnListState + ) { + items( + items = linkList, + key = { it.id } + ) { link -> + LinkCard( + item = link, + title = link.title, + sub = "${link.dateString} · ${link.domainUrl}", + painter = rememberAsyncImagePainter(link.imageUrl), + notRead = !link.isRead, + badgeText = link.pokitName, + onClickKebab = showLinkModifyBottomSheet, + onClickItem = onClickLink, + modifier = Modifier.padding(20.dp) + ) - HorizontalDivider( - modifier = Modifier.padding(horizontal = 20.dp), - thickness = 1.dp, - color = PokitTheme.colors.borderTertiary - ) + HorizontalDivider( + modifier = Modifier.padding(horizontal = 20.dp), + thickness = 1.dp, + color = PokitTheme.colors.borderTertiary + ) + } } } } } + Image( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = 48.dp, end = 20.dp) + .size(60.dp) + .clip(shape = CircleShape) + .background(color = PokitTheme.colors.brand) + .clickable { + state.currentPokit?.let { currentPokit -> + onClickAddLink(currentPokit.id, currentPokit.title) + } + } + .padding(12.dp), + painter = painterResource(id = coreDrawable.icon_24_plus), + contentDescription = "add link", + colorFilter = ColorFilter.tint(color = PokitTheme.colors.inverseWh) + ) + if (state.currentLink != null) { val context: Context = LocalContext.current LinkDetailBottomSheet( @@ -365,7 +401,7 @@ fun PokitDetailScreen( onClickModify = remember { { hidePokitModifyBottomSheet() - onClickPokitModify(state.currentPokit!!.id) // TODO assertion 제거하는 방향으로 + onClickPokitModify(state.currentPokit!!.id) } }, onClickRemove = showPokitRemoveBottomSheet diff --git a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailViewModel.kt b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailViewModel.kt index d4705edf..10e1bdb3 100644 --- a/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailViewModel.kt +++ b/feature/pokitdetail/src/main/java/com/strayalpaca/pokitdetail/PokitDetailViewModel.kt @@ -111,11 +111,21 @@ class PokitDetailViewModel @Inject constructor( getPokit(id, linkCount) } + initLinkAddEventDetector() initLinkUpdateEventDetector() initLinkRemoveEventDetector() initPokitUpdateEventDetector() } + private fun initLinkAddEventDetector() { + viewModelScope.launch { + LinkUpdateEvent.addedLink.collectLatest { addedLink -> + if (state.value.currentPokit?.id != addedLink.pokitId.toString()) return@collectLatest + linkPaging.refresh() + } + } + } + private fun initLinkUpdateEventDetector() { viewModelScope.launch { LinkUpdateEvent.updatedLink.collectLatest { updatedLink -> diff --git a/feature/search/src/main/res/values/string.xml b/feature/search/src/main/res/values/string.xml index 90377eb6..a1e316d6 100644 --- a/feature/search/src/main/res/values/string.xml +++ b/feature/search/src/main/res/values/string.xml @@ -1,6 +1,6 @@ - 내용을 입력해주세요. + 제목, 메모를 검색해보세요. 최근 검색어 전체 삭제 diff --git a/settings.gradle.kts b/settings.gradle.kts index 72323e74..691bb5fd 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,3 +35,4 @@ include(":feature:settings") include(":feature:alarm") include(":feature:home") include(":core:feature") +include(":feature:linklist")