diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 00000000..d13a6e24 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,13 @@ +# AGENTS + +This repository keeps its project guidance in two documents: + +- Read [README.md](./README.md) first for product scope, build steps, and required checks. +- Read [CONVENTION.md](./CONVENTION.md) for reviewer-enforced coding rules under `app/src/main/`. + +Minimum expectations for any change: + +- Keep changes consistent with `README.md` and `CONVENTION.md`. +- Run `./gradlew :app:lintDebug` before pushing. +- Do not introduce new `!!` in production code. +- For paged post feeds, keep using the existing Paging overlay and deduplication patterns. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 120000 index 00000000..47dc3e3d --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/README.md b/README.md index c40d2155..c21f025d 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ An Android client for [Hackers' Pub](https://hackers.pub), a fediverse-compatibl - **Search**: Find posts and users across the fediverse - **Compose**: Create posts with Markdown support, mention autocomplete, and visibility controls - **Drafts**: Save and resume in-progress posts +- **Bookmarks**: Save posts and browse bookmarked articles or notes - **Profiles**: View user profiles and their posts; recommended-actor discovery - **Post Details**: See full posts with replies and reactions - **Translation**: On-device post translation via ML Kit @@ -139,6 +140,7 @@ For the rules reviewers enforce during PRs — null-safety, Paging config, threa | Sign In | Two-step email verification flow | | Compose | New post creation with visibility options and mention autocomplete | | Drafts | Saved drafts list with resume/delete | +| Bookmarks | Saved posts with article/note filtering | | Post Detail | Full post view with replies and reactions | | Profile | User profile with bio and posts | | Recommended Actors | Suggested accounts to follow | diff --git a/app/src/main/graphql/pub/hackers/android/operations.graphql b/app/src/main/graphql/pub/hackers/android/operations.graphql index c32b09e1..267eb133 100644 --- a/app/src/main/graphql/pub/hackers/android/operations.graphql +++ b/app/src/main/graphql/pub/hackers/android/operations.graphql @@ -33,6 +33,7 @@ fragment PostFields on Post { url iri viewerHasShared + viewerHasBookmarked actor { ...ActorFields } @@ -102,6 +103,7 @@ fragment SharedPostFields on Post { url iri viewerHasShared + viewerHasBookmarked actor { ...ActorFields } @@ -462,6 +464,24 @@ query PostReplies($id: ID!, $after: String) { } } +query Bookmarks($after: String, $postType: PostType) { + bookmarks(first: 20, after: $after, postType: $postType) { + edges { + cursor + node { + ...PostFields + sharedPost { + ...SharedPostFields + } + } + } + pageInfo { + hasNextPage + endCursor + } + } +} + mutation LoginByUsername($username: String!, $locale: Locale!, $verifyUrl: URITemplate!) { loginByUsername(username: $username, locale: $locale, verifyUrl: $verifyUrl) { ... on LoginChallenge { @@ -519,6 +539,31 @@ mutation VerifyPasskeyRegistration($accountId: ID!, $name: String!, $registratio } } +mutation BookmarkPost($postId: ID!) { + bookmarkPost(input: { postId: $postId }) { + __typename + ... on BookmarkPostPayload { + post { + id + viewerHasBookmarked + } + } + } +} + +mutation UnbookmarkPost($postId: ID!) { + unbookmarkPost(input: { postId: $postId }) { + __typename + ... on UnbookmarkPostPayload { + post { + id + viewerHasBookmarked + } + unbookmarkedPostId + } + } +} + mutation RevokePasskey($passkeyId: ID!) { revokePasskey(passkeyId: $passkeyId) } diff --git a/app/src/main/graphql/pub/hackers/android/schema.graphqls b/app/src/main/graphql/pub/hackers/android/schema.graphqls index e16478ef..6b3b3397 100644 --- a/app/src/main/graphql/pub/hackers/android/schema.graphqls +++ b/app/src/main/graphql/pub/hackers/android/schema.graphqls @@ -484,6 +484,8 @@ type Article implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -561,6 +563,20 @@ type BlockActorPayload { union BlockActorResult = BlockActorPayload|InvalidInputError|NotAuthenticatedError +input BookmarkPostInput { + clientMutationId: ID + + postId: ID! +} + +type BookmarkPostPayload { + clientMutationId: ID + + post: Post! +} + +union BookmarkPostResult = BookmarkPostPayload|InvalidInputError|NotAuthenticatedError + union CreateInvitationLinkResult = InvalidInputError|InvitationLinkPayload|NotAuthenticatedError input CreateNoteInput { @@ -885,6 +901,8 @@ type Mutation { blockActor(input: BlockActorInput!): BlockActorResult! + bookmarkPost(input: BookmarkPostInput!): BookmarkPostResult! + completeLoginChallenge("The code of the login challenge." code: String!, "The token of the login challenge." token: UUID!): Session """ @@ -939,6 +957,8 @@ type Mutation { unblockActor(input: UnblockActorInput!): UnblockActorResult! + unbookmarkPost(input: UnbookmarkPostInput!): UnbookmarkPostResult! + unfollowActor(input: UnfollowActorInput!): UnfollowActorResult! unregisterApnsDeviceToken(input: UnregisterApnsDeviceTokenInput!): UnregisterApnsDeviceTokenResult! @@ -1011,6 +1031,8 @@ type Note implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1207,6 +1229,8 @@ interface Post implements Node & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1381,6 +1405,8 @@ type Query { availableLocales: [Locale!]! + bookmarks(after: String, before: String, first: Int, last: Int, postType: PostType): QueryBookmarksConnection! + codeOfConduct("The locale for the Code of Conduct." locale: Locale!): Document! instanceByHost(host: String!): Instance @@ -1429,6 +1455,18 @@ type Query { viewer: Account } +type QueryBookmarksConnection { + edges: [QueryBookmarksConnectionEdge!]! + + pageInfo: PageInfo! +} + +type QueryBookmarksConnectionEdge { + cursor: String! + + node: Post! +} + type QueryPersonalTimelineConnection { edges: [QueryPersonalTimelineConnectionEdge!]! @@ -1530,6 +1568,8 @@ type Question implements Node & Post & Reactable { uuid: UUID! + viewerHasBookmarked: Boolean! + viewerHasShared: Boolean! visibility: PostVisibility! @@ -1896,6 +1936,22 @@ type UnblockActorPayload { union UnblockActorResult = InvalidInputError|NotAuthenticatedError|UnblockActorPayload +input UnbookmarkPostInput { + clientMutationId: ID + + postId: ID! +} + +type UnbookmarkPostPayload { + clientMutationId: ID + + post: Post! + + unbookmarkedPostId: ID! +} + +union UnbookmarkPostResult = InvalidInputError|NotAuthenticatedError|UnbookmarkPostPayload + input UnfollowActorInput { actorId: ID! @@ -2023,4 +2079,3 @@ type WebFingerResult { url: URL } - diff --git a/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt b/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt index d0e7dd7a..245033f0 100644 --- a/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt +++ b/app/src/main/java/pub/hackers/android/data/paging/CursorPagingSource.kt @@ -115,4 +115,10 @@ suspend fun HackersPubRepository.actorArticlesPage(handle: String, after: String getActorArticles(handle, after) .map { CursorPage(it.posts, it.endCursor, it.hasNextPage) } +suspend fun HackersPubRepository.bookmarksPage( + after: String?, + postType: pub.hackers.android.graphql.type.PostType?, +) = getBookmarks(after, postType) + .map { CursorPage(it.posts, it.endCursor, it.hasNextPage) } + // endregion diff --git a/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt b/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt index 2585337b..971b1256 100644 --- a/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt +++ b/app/src/main/java/pub/hackers/android/data/paging/PostOverlay.kt @@ -22,6 +22,7 @@ import pub.hackers.android.domain.model.ReactionGroup @Immutable data class PostOverlay( val viewerHasShared: Boolean? = null, // null = no override + val viewerHasBookmarked: Boolean? = null, // null = no override val shareDelta: Int = 0, // added to engagementStats.shares val reactionOverride: List? = null, // full replacement when we touched reactions val reactionCountOverride: Int? = null, // engagementStats.reactions override @@ -34,6 +35,7 @@ fun Post.applyOverlay(overlay: PostOverlay?): Post { if (overlay == null) return this return copy( viewerHasShared = overlay.viewerHasShared ?: viewerHasShared, + viewerHasBookmarked = overlay.viewerHasBookmarked ?: viewerHasBookmarked, engagementStats = engagementStats.copy( shares = maxOf(0, engagementStats.shares + overlay.shareDelta), reactions = overlay.reactionCountOverride ?: engagementStats.reactions, diff --git a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt index 142b2d5f..64613e19 100644 --- a/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt +++ b/app/src/main/java/pub/hackers/android/data/repository/HackersPubRepository.kt @@ -14,6 +14,8 @@ import pub.hackers.android.graphql.ActorByHandleQuery import pub.hackers.android.graphql.ActorNotesQuery import pub.hackers.android.graphql.ActorPostsQuery import pub.hackers.android.graphql.AddReactionToPostMutation +import pub.hackers.android.graphql.BookmarkPostMutation +import pub.hackers.android.graphql.BookmarksQuery import pub.hackers.android.graphql.BlockActorMutation import pub.hackers.android.graphql.CompleteLoginChallengeMutation import pub.hackers.android.graphql.CreateNoteMutation @@ -50,6 +52,7 @@ import pub.hackers.android.graphql.SearchPostQuery import pub.hackers.android.graphql.SharePostMutation import pub.hackers.android.graphql.UnblockActorMutation import pub.hackers.android.graphql.UnfollowActorMutation +import pub.hackers.android.graphql.UnbookmarkPostMutation import pub.hackers.android.graphql.UnsharePostMutation import pub.hackers.android.graphql.UpdateAccountMutation import pub.hackers.android.graphql.ViewerQuery @@ -196,6 +199,36 @@ class HackersPubRepository @Inject constructor( } } + suspend fun bookmarkPost(postId: String): Result { + return try { + val response = apolloClient.mutation(BookmarkPostMutation(postId)).execute() + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else if (response.data?.bookmarkPost?.onBookmarkPostPayload != null) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to bookmark")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + + suspend fun unbookmarkPost(postId: String): Result { + return try { + val response = apolloClient.mutation(UnbookmarkPostMutation(postId)).execute() + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else if (response.data?.unbookmarkPost?.onUnbookmarkPostPayload != null) { + Result.success(Unit) + } else { + Result.failure(Exception("Failed to remove bookmark")) + } + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun searchPosts( query: String, languages: List @@ -221,6 +254,39 @@ class HackersPubRepository @Inject constructor( } } + suspend fun getBookmarks( + after: String? = null, + postType: pub.hackers.android.graphql.type.PostType? = null, + ): Result { + return try { + val response = apolloClient.query( + BookmarksQuery( + after = Optional.presentIfNotNull(after), + postType = Optional.presentIfNotNull(postType) + ) + ).fetchPolicy(FetchPolicy.NetworkOnly).execute() + + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else { + val data = response.data?.bookmarks + withContext(Dispatchers.Default) { + Result.success( + TimelineResult( + posts = data?.edges?.map { edge -> + edge.node.postFields.toPost(edge.node.sharedPost?.sharedPostFields?.toPost()) + } ?: emptyList(), + hasNextPage = data?.pageInfo?.hasNextPage ?: false, + endCursor = data?.pageInfo?.endCursor + ) + ) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun getPostDetail(id: String, repliesAfter: String? = null): Result { return try { val response = apolloClient.query( @@ -1399,6 +1465,7 @@ class HackersPubRepository @Inject constructor( url = url?.toString(), iri = iri.toString(), viewerHasShared = viewerHasShared, + viewerHasBookmarked = viewerHasBookmarked, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, link = link?.let { l -> @@ -1465,6 +1532,7 @@ class HackersPubRepository @Inject constructor( url = url?.toString(), iri = iri.toString(), viewerHasShared = viewerHasShared, + viewerHasBookmarked = viewerHasBookmarked, actor = actor.actorFields.toActor(), media = media.map { it.mediaFields.toMedia() }, engagementStats = engagementStats.engagementStatsFields.toEngagementStats(), diff --git a/app/src/main/java/pub/hackers/android/domain/model/Models.kt b/app/src/main/java/pub/hackers/android/domain/model/Models.kt index 46496523..de70bd1c 100644 --- a/app/src/main/java/pub/hackers/android/domain/model/Models.kt +++ b/app/src/main/java/pub/hackers/android/domain/model/Models.kt @@ -78,6 +78,7 @@ data class Post( val url: String?, val iri: String? = null, val viewerHasShared: Boolean, + val viewerHasBookmarked: Boolean = false, val actor: Actor, val media: List, val link: PostLink? = null, diff --git a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt index 021c2c78..c737fca9 100644 --- a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt +++ b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt @@ -41,6 +41,7 @@ import pub.hackers.android.ui.components.BottomNavItem import pub.hackers.android.ui.components.LocalFontScale import pub.hackers.android.ui.components.ProvideInAppBrowserUriHandler import pub.hackers.android.ui.screens.auth.SignInScreen +import pub.hackers.android.ui.screens.bookmarks.BookmarksScreen import pub.hackers.android.ui.screens.compose.ComposeArticleScreen import pub.hackers.android.ui.screens.compose.ComposeScreen import pub.hackers.android.ui.screens.drafts.DraftsScreen @@ -144,6 +145,7 @@ sealed class DetailScreen(val route: String) { } } data object Drafts : DetailScreen("drafts") + data object Bookmarks : DetailScreen("bookmarks") data object EditProfile : DetailScreen("edit-profile") data object WebView : DetailScreen("webview?url={url}") { fun createRoute(url: String): String { @@ -447,6 +449,9 @@ fun HackersPubApp( onDraftsClick = { navController.navigate(DetailScreen.Drafts.route) }, + onBookmarksClick = { + navController.navigate(DetailScreen.Bookmarks.route) + }, onLicensesClick = { navController.navigate(DetailScreen.Licenses.route) }, @@ -647,6 +652,26 @@ fun HackersPubApp( ) } + composable(DetailScreen.Bookmarks.route) { + BookmarksScreen( + onNavigateBack = { + navController.popBackStack() + }, + onPostClick = { postId -> + navController.navigate(DetailScreen.PostDetail.createRoute(postId)) + }, + onProfileClick = { handle -> + navController.navigate(DetailScreen.Profile.createRoute(handle)) + }, + onReplyClick = { postId -> + navController.navigate(DetailScreen.Compose.createRoute(replyTo = postId)) + }, + onQuoteClick = { postId -> + navController.navigate(DetailScreen.Compose.createRoute(quoteOf = postId)) + }, + ) + } + composable(DetailScreen.RecommendedActors.route) { RecommendedActorsScreen( onNavigateBack = { diff --git a/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt b/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt new file mode 100644 index 00000000..89324a9c --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/bookmark/BookmarkMutationCoordinator.kt @@ -0,0 +1,52 @@ +package pub.hackers.android.ui.bookmark + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Serializes bookmark mutations per post while preserving optimistic UI state. + * + * The UI is updated immediately to the user's latest desired state. Network + * requests are sent one at a time per post id; if the user toggles again while + * a request is in flight, we remember the last desired state and enqueue a + * follow-up request after the current one completes. + */ +class BookmarkMutationCoordinator( + private val scope: CoroutineScope, + private val requestMutation: suspend (postId: String, shouldBookmark: Boolean) -> Result, + private val applyDesiredState: (postId: String, isBookmarked: Boolean) -> Unit, + private val revertFailedState: (postId: String, attemptedState: Boolean) -> Unit, +) { + private val inFlight = mutableSetOf() + private val lastDesired = mutableMapOf() + + fun toggle(postId: String, currentIsBookmarked: Boolean) { + val desiredState = !currentIsBookmarked + lastDesired[postId] = desiredState + applyDesiredState(postId, desiredState) + + if (!inFlight.add(postId)) return + sync(postId, desiredState) + } + + private fun sync(postId: String, desiredState: Boolean) { + scope.launch { + val result = requestMutation(postId, desiredState) + val latestDesired = lastDesired[postId] + + if (result.isFailure && latestDesired == desiredState) { + revertFailedState(postId, desiredState) + lastDesired.remove(postId) + } else if (result.isSuccess && latestDesired == desiredState) { + lastDesired.remove(postId) + } + + inFlight.remove(postId) + + val nextDesired = lastDesired[postId] + if (nextDesired != null && nextDesired != desiredState && inFlight.add(postId)) { + sync(postId, nextDesired) + } + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt index a8ae22c6..1d4b5cef 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/ArticleCard.kt @@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.Reply +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Favorite import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Share import androidx.compose.material3.Card @@ -57,6 +59,7 @@ fun ArticleCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null ) { val displayPost = post.sharedPost ?: post @@ -218,6 +221,7 @@ fun ArticleCard( onShareClick = onShareClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick ) } @@ -232,11 +236,13 @@ private fun ArticleEngagementBar( onShareClick: (() -> Unit)?, onReactionClick: (() -> Unit)?, onReactionLongPress: (() -> Unit)?, + onBookmarkClick: (() -> Unit)?, onExternalShareClick: (() -> Unit)? ) { val colors = LocalAppColors.current val isShared = post.viewerHasShared val isReacted = post.reactionGroups.any { it.viewerHasReacted } + val isBookmarked = post.viewerHasBookmarked Row( modifier = Modifier @@ -274,6 +280,15 @@ private fun ArticleEngagementBar( tint = if (isReacted) colors.reaction else colors.textSecondary ) } + if (onBookmarkClick != null) { + IconButton(onClick = onBookmarkClick) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (isBookmarked) colors.bookmark else colors.textSecondary + ) + } + } if (onExternalShareClick != null) { IconButton(onClick = onExternalShareClick) { Icon( diff --git a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt index 14aa4613..0713220f 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/PostCard.kt @@ -36,9 +36,11 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Download import androidx.compose.material.icons.filled.Favorite +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.PlayArrow import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.ChatBubbleOutline import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.FormatQuote @@ -107,6 +109,7 @@ fun PostCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null, onQuotedPostClick: ((String) -> Unit)? = null, contentMaxLength: Int = 0 @@ -123,6 +126,7 @@ fun PostCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick, modifier = modifier ) @@ -136,6 +140,7 @@ fun PostCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick, onQuotedPostClick = onQuotedPostClick, contentMaxLength = contentMaxLength, @@ -155,6 +160,7 @@ private fun NoteCard( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null, onQuotedPostClick: ((String) -> Unit)? = null, contentMaxLength: Int = 0 @@ -541,6 +547,7 @@ private fun NoteCard( onQuoteClick = onQuoteClick, onReactionClick = onReactionClick, onReactionLongPress = onReactionLongPress, + onBookmarkClick = onBookmarkClick, onExternalShareClick = onExternalShareClick ) } @@ -557,12 +564,14 @@ private fun EngagementBar( onQuoteClick: (() -> Unit)? = null, onReactionClick: (() -> Unit)? = null, onReactionLongPress: (() -> Unit)? = null, + onBookmarkClick: (() -> Unit)? = null, onExternalShareClick: (() -> Unit)? = null ) { val colors = LocalAppColors.current val isReplied = post.engagementStats.replies > 0 && post.replyTarget != null val isShared = post.viewerHasShared val isReacted = post.reactionGroups.any { it.viewerHasReacted } + val isBookmarked = post.viewerHasBookmarked Row( modifier = Modifier @@ -596,6 +605,13 @@ private fun EngagementBar( onLongClick = onReactionLongPress ) + if (onBookmarkClick != null) { + BookmarkEngagementButton( + isBookmarked = isBookmarked, + onClick = onBookmarkClick, + ) + } + Spacer(modifier = Modifier.weight(1f)) // External share — always textSecondary, offset back to align right edge @@ -615,6 +631,30 @@ private fun EngagementBar( } } +@Composable +private fun BookmarkEngagementButton( + isBookmarked: Boolean, + onClick: (() -> Unit)?, +) { + val colors = LocalAppColors.current + val tint = if (isBookmarked) colors.bookmark else colors.textSecondary + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable(enabled = onClick != null) { onClick?.invoke() } + ) { + Icon( + imageVector = if (isBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = tint, + modifier = Modifier.size(20.dp) + ) + } +} + @Composable private fun EngagementButton( icon: androidx.compose.ui.graphics.vector.ImageVector, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt new file mode 100644 index 00000000..5f023593 --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksScreen.kt @@ -0,0 +1,298 @@ +package pub.hackers.android.ui.screens.bookmarks + +import android.content.Intent +import android.widget.Toast +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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.Arrangement +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.rememberLazyListState +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Bookmark +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.itemKey +import pub.hackers.android.R +import pub.hackers.android.ui.components.ErrorMessage +import pub.hackers.android.ui.components.FullScreenLoading +import pub.hackers.android.ui.components.LargeTitleHeader +import pub.hackers.android.ui.components.LoadingItem +import pub.hackers.android.ui.components.PostCard +import pub.hackers.android.ui.components.ReactionPicker +import pub.hackers.android.ui.theme.LocalAppColors +import pub.hackers.android.ui.theme.LocalAppTypography + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun BookmarksScreen( + onNavigateBack: () -> Unit, + onPostClick: (String) -> Unit, + onProfileClick: (String) -> Unit, + onReplyClick: (String) -> Unit, + onQuoteClick: (String) -> Unit, + viewModel: BookmarksViewModel = hiltViewModel(), +) { + val items = viewModel.posts.collectAsLazyPagingItems() + val selectedTab by viewModel.selectedTab.collectAsState() + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + + LaunchedEffect(selectedTab) { + listState.scrollToItem(0) + } + + val pickerPostId = uiState.reactionPickerPostId + if (pickerPostId != null) { + val pickerPost = items.itemSnapshotList.items.find { + it.id == pickerPostId || it.sharedPost?.id == pickerPostId + } + val targetPost = pickerPost?.sharedPost ?: pickerPost + ModalBottomSheet( + onDismissRequest = { viewModel.hideReactionPicker() }, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true) + ) { + ReactionPicker( + reactionGroups = targetPost?.reactionGroups ?: emptyList(), + isSubmitting = false, + onEmojiSelect = { emoji -> + pickerPost?.let { viewModel.toggleReaction(it, emoji) } + }, + onClose = { viewModel.hideReactionPicker() } + ) + } + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + LargeTitleHeader( + title = stringResource(R.string.bookmarks), + leadingContent = { + IconButton(onClick = onNavigateBack) { + Icon( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + contentDescription = stringResource(R.string.back), + tint = colors.accent + ) + } + } + ) + } + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues) + ) { + Row(modifier = Modifier.fillMaxWidth()) { + BookmarkTab.entries.forEach { tab -> + val isSelected = selectedTab == tab + Column( + modifier = Modifier + .weight(1f) + .clickable { viewModel.selectTab(tab) }, + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text( + text = when (tab) { + BookmarkTab.ALL -> stringResource(R.string.all) + BookmarkTab.ARTICLES -> stringResource(R.string.articles) + BookmarkTab.NOTES -> stringResource(R.string.notes) + }, + style = if (isSelected) typography.bodyLargeSemiBold else typography.bodyLarge, + color = if (isSelected) colors.accent else colors.textSecondary, + modifier = Modifier.padding(vertical = 12.dp) + ) + Box( + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .background(if (isSelected) colors.accent else colors.surface) + ) + } + } + } + + HorizontalDivider( + color = colors.divider, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Box(modifier = Modifier.fillMaxSize()) { + val refresh = items.loadState.refresh + when { + refresh is LoadState.Error && items.itemCount == 0 -> { + ErrorMessage( + message = refresh.error.message ?: stringResource(R.string.error_generic), + onRetry = { items.refresh() } + ) + } + + items.itemCount == 0 && refresh is LoadState.NotLoading -> { + BookmarksEmptyState( + onRefresh = { items.refresh() } + ) + } + + refresh is LoadState.Loading && items.itemCount == 0 -> { + FullScreenLoading() + } + + else -> { + PullToRefreshBox( + isRefreshing = refresh is LoadState.Loading, + onRefresh = { items.refresh() } + ) { + LazyColumn(state = listState) { + items( + count = items.itemCount, + key = items.itemKey { it.sharedPost?.id ?: it.id } + ) { index -> + val post = items[index] ?: return@items + PostCard( + post = post, + onClick = { onPostClick(post.sharedPost?.id ?: post.id) }, + onProfileClick = onProfileClick, + onReplyClick = { + onReplyClick(post.sharedPost?.id ?: post.id) + }, + onShareClick = { + val targetId = post.sharedPost?.id ?: post.id + if (post.viewerHasShared) { + viewModel.unsharePost(targetId) + } else { + viewModel.sharePost(targetId) + } + }, + onQuoteClick = { + onQuoteClick(post.sharedPost?.id ?: post.id) + }, + onReactionClick = { + viewModel.toggleFavourite(post) + }, + onReactionLongPress = { + viewModel.showReactionPicker(post.sharedPost?.id ?: post.id) + }, + onBookmarkClick = { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + }, + onExternalShareClick = { + val displayPost = post.sharedPost ?: post + val shareUrl = displayPost.url ?: displayPost.iri + if (shareUrl != null) { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + putExtra(Intent.EXTRA_TEXT, shareUrl) + type = "text/plain" + } + context.startActivity(Intent.createChooser(sendIntent, null)) + } + }, + onQuotedPostClick = onPostClick + ) + HorizontalDivider( + color = colors.divider, + thickness = 1.dp, + modifier = Modifier.padding(horizontal = 16.dp) + ) + } + + if (items.loadState.append is LoadState.Loading) { + item { LoadingItem() } + } + } + } + } + } + } + } + } +} + +@Composable +private fun BookmarksEmptyState( + onRefresh: () -> Unit, +) { + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + + Column( + modifier = Modifier + .fillMaxSize() + .padding(32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.Center, + ) { + Icon( + imageVector = Icons.Filled.Bookmark, + contentDescription = null, + modifier = Modifier.size(48.dp), + tint = colors.bookmark, + ) + Spacer(modifier = Modifier.height(16.dp)) + Text( + text = stringResource(R.string.no_bookmarks), + style = typography.bodyLargeSemiBold, + color = colors.textPrimary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = stringResource(R.string.no_bookmarks_description), + style = typography.bodyMedium, + color = colors.textSecondary, + textAlign = TextAlign.Center, + ) + Spacer(modifier = Modifier.height(20.dp)) + androidx.compose.material3.Button(onClick = onRefresh) { + Text(stringResource(R.string.refresh)) + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt new file mode 100644 index 00000000..f042d9e5 --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/bookmarks/BookmarksViewModel.kt @@ -0,0 +1,199 @@ +package pub.hackers.android.ui.screens.bookmarks + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +import androidx.paging.filter +import androidx.paging.map +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import pub.hackers.android.data.paging.PostOverlayStore +import pub.hackers.android.data.paging.applyOverlays +import pub.hackers.android.data.paging.bookmarksPage +import pub.hackers.android.data.paging.cursorPager +import pub.hackers.android.data.paging.distinctByEffectiveId +import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.Post +import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.graphql.type.PostType as GqlPostType +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator +import javax.inject.Inject + +enum class BookmarkTab { + ALL, ARTICLES, NOTES +} + +data class BookmarksUiState( + val reactionPickerPostId: String? = null, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class BookmarksViewModel @Inject constructor( + private val repository: HackersPubRepository, +) : ViewModel() { + + private val _selectedTab = MutableStateFlow(BookmarkTab.ALL) + val selectedTab: StateFlow = _selectedTab.asStateFlow() + + private val _uiState = MutableStateFlow(BookmarksUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) + + val posts: Flow> = _selectedTab + .flatMapLatest { tab -> + cursorPager { after -> + repository.bookmarksPage(after, tab.toGraphqlPostType()) + }.flow.distinctByEffectiveId().cachedIn(viewModelScope) + } + .combine(overlayStore.overlays) { paging, overlays -> + paging + .map { post -> post.applyOverlays(overlays) } + .filter { post -> (post.sharedPost ?: post).viewerHasBookmarked } + } + .cachedIn(viewModelScope) + + fun selectTab(tab: BookmarkTab) { + if (_selectedTab.value != tab) _selectedTab.value = tab + } + + fun sharePost(postId: String) { + overlayStore.mutate(postId) { + it.copy(viewerHasShared = true, shareDelta = it.shareDelta + 1) + } + viewModelScope.launch { + repository.sharePost(postId).onFailure { + overlayStore.mutate(postId) { prev -> + prev.copy( + viewerHasShared = false, + shareDelta = prev.shareDelta - 1, + ) + } + } + } + } + + fun unsharePost(postId: String) { + overlayStore.mutate(postId) { + it.copy(viewerHasShared = false, shareDelta = it.shareDelta - 1) + } + viewModelScope.launch { + repository.unsharePost(postId).onFailure { + overlayStore.mutate(postId) { prev -> + prev.copy( + viewerHasShared = true, + shareDelta = prev.shareDelta + 1, + ) + } + } + } + } + + fun toggleFavourite(post: Post) { + toggleReaction(post, "❤️") + } + + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) + } + + fun toggleReaction(post: Post, emoji: String) { + val target = post.sharedPost ?: post + val existing = target.reactionGroups.find { it.emoji == emoji } + val wasReacted = existing?.viewerHasReacted == true + + val updatedGroups = computeToggledReactionGroups(target.reactionGroups, emoji, wasReacted) + + overlayStore.mutate(target.id) { prev -> + prev.copy( + reactionOverride = updatedGroups, + reactionCountOverride = updatedGroups.sumOf { it.count }, + ) + } + _uiState.update { it.copy(reactionPickerPostId = null) } + + viewModelScope.launch { + val result = if (wasReacted) { + repository.removeReactionFromPost(target.id, emoji) + } else { + repository.addReactionToPost(target.id, emoji) + } + result.onFailure { + overlayStore.clear(target.id) + } + } + } + + fun showReactionPicker(postId: String) { + _uiState.update { it.copy(reactionPickerPostId = postId) } + } + + fun hideReactionPicker() { + _uiState.update { it.copy(reactionPickerPostId = null) } + } + + private fun computeToggledReactionGroups( + groups: List, + emoji: String, + wasReacted: Boolean, + ): List { + return if (wasReacted) { + groups.map { group -> + if (group.emoji == emoji) { + group.copy(count = maxOf(0, group.count - 1), viewerHasReacted = false) + } else group + }.filter { it.count > 0 || it.viewerHasReacted } + } else { + val existing = groups.find { it.emoji == emoji } + if (existing != null) { + groups.map { group -> + if (group.emoji == emoji) { + group.copy(count = group.count + 1, viewerHasReacted = true) + } else group + } + } else { + groups + ReactionGroup( + emoji = emoji, + customEmoji = null, + count = 1, + reactors = emptyList(), + viewerHasReacted = true, + ) + } + } + } + + private fun BookmarkTab.toGraphqlPostType(): GqlPostType? = when (this) { + BookmarkTab.ALL -> null + BookmarkTab.ARTICLES -> GqlPostType.ARTICLE + BookmarkTab.NOTES -> GqlPostType.NOTE + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt index ce2d5669..f2f92572 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreScreen.kt @@ -1,6 +1,7 @@ package pub.hackers.android.ui.screens.explore import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box @@ -60,6 +61,8 @@ fun ExploreScreen( val uiState by viewModel.uiState.collectAsState() val listState = rememberLazyListState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current val typography = LocalAppTypography.current @@ -215,6 +218,21 @@ fun ExploreScreen( ) } } else null, + onBookmarkClick = if (isLoggedIn) { + { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + } + } else null, onExternalShareClick = { val displayPost = post.sharedPost ?: post val shareUrl = displayPost.url ?: displayPost.iri diff --git a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt index 4fded8cd..21e0d1fc 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/explore/ExploreViewModel.kt @@ -24,6 +24,7 @@ import pub.hackers.android.data.paging.publicTimelinePage import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject enum class ExploreTab { @@ -48,6 +49,23 @@ class ExploreViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) val posts: Flow> = _selectedTab .flatMapLatest { tab -> @@ -103,6 +121,11 @@ class ExploreViewModel @Inject constructor( toggleReaction(post, "❤️") } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) + } + /** * Optimistically toggle a reaction on [post] (or its sharedPost target). * The overlay is computed from the post's current reactionGroups (possibly diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt index 49407dd4..cbe0fcc9 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailScreen.kt @@ -4,6 +4,7 @@ import android.annotation.SuppressLint import android.content.Intent import android.text.Html import android.webkit.WebView +import android.widget.Toast import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.verticalScroll @@ -33,11 +34,13 @@ import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.automirrored.filled.Reply import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.MoreVert import androidx.compose.material.icons.filled.Public import androidx.compose.material.icons.filled.Repeat import androidx.compose.material.icons.outlined.AddReaction +import androidx.compose.material.icons.outlined.BookmarkBorder import androidx.compose.material.icons.outlined.FormatQuote import androidx.compose.material.icons.outlined.Group import androidx.compose.material.icons.outlined.Lock @@ -145,6 +148,8 @@ fun PostDetailScreen( ) { val uiState by viewModel.uiState.collectAsState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current val confirmBeforeDelete by viewModel.preferencesManager.confirmBeforeDelete.collectAsState( initial = true @@ -485,6 +490,22 @@ fun PostDetailScreen( }, onReactionClick = { group -> viewModel.showReactorsSheet(group) }, onReactionPickerClick = { viewModel.toggleReactionPicker() }, + onBookmarkClick = if (isLoggedIn) { + { + Toast.makeText( + context, + if (resolvedPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark() + } + } else { + null + }, onQuoteClick = { onQuoteClick(postId) }, onSharesClick = { viewModel.showSharesSheet() }, onQuotesClick = { viewModel.showQuotesSheet() }, @@ -571,6 +592,7 @@ internal fun PostDetailContent( onReplyClick: () -> Unit, onReactionClick: (ReactionGroup) -> Unit, onReactionPickerClick: () -> Unit, + onBookmarkClick: (() -> Unit)?, onQuoteClick: () -> Unit, onSharesClick: () -> Unit, onQuotesClick: () -> Unit, @@ -977,6 +999,15 @@ internal fun PostDetailContent( colors.textSecondary ) } + if (onBookmarkClick != null) { + IconButton(onClick = onBookmarkClick) { + Icon( + imageVector = if (post.viewerHasBookmarked) Icons.Filled.Bookmark else Icons.Outlined.BookmarkBorder, + contentDescription = stringResource(R.string.bookmark), + tint = if (post.viewerHasBookmarked) colors.bookmark else colors.textSecondary + ) + } + } IconButton(onClick = onQuoteClick) { Icon( imageVector = Icons.Outlined.FormatQuote, diff --git a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt index fad2859b..1167e815 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/postdetail/PostDetailViewModel.kt @@ -23,6 +23,7 @@ import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup import pub.hackers.android.domain.model.TocItem +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import pub.hackers.android.ui.screens.compose.ReplyPostedSignal import javax.inject.Inject @@ -62,6 +63,27 @@ class PostDetailViewModel @Inject constructor( private val _uiState = MutableStateFlow(PostDetailUiState()) val uiState: StateFlow = _uiState.asStateFlow() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { targetPostId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(targetPostId) + else repository.unbookmarkPost(targetPostId) + }, + applyDesiredState = { targetPostId, isBookmarked -> + _uiState.update { state -> + val post = state.post + if (post == null || post.id != targetPostId) state + else state.copy(post = post.copy(viewerHasBookmarked = isBookmarked)) + } + }, + revertFailedState = { targetPostId, attemptedState -> + _uiState.update { state -> + val post = state.post + if (post == null || post.id != targetPostId) state + else state.copy(post = post.copy(viewerHasBookmarked = !attemptedState)) + } + }, + ) // Locally-composed replies appended optimistically after a successful reply // from this screen. Rendered after the paginated replies; cleared on refresh @@ -286,6 +308,11 @@ class PostDetailViewModel @Inject constructor( } } + fun toggleBookmark() { + val post = _uiState.value.post ?: return + bookmarkCoordinator.toggle(post.id, post.viewerHasBookmarked) + } + fun toggleReaction(emoji: String) { if (_uiState.value.isReacting) return diff --git a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt index 1f8af840..3f2dc805 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/profile/ProfileViewModel.kt @@ -28,6 +28,7 @@ import pub.hackers.android.domain.model.Actor import pub.hackers.android.domain.model.ActorField import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject enum class ProfileTab { @@ -65,6 +66,23 @@ class ProfileViewModel @Inject constructor( val selectedTab: StateFlow = _selectedTab.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) // Each tab has its own pager. Screen side only collects the active tab's // flow, so inactive tabs don't fetch until the user switches to them. @@ -270,6 +288,11 @@ class ProfileViewModel @Inject constructor( } } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) + } + @Suppress("unused") fun toggleReaction(post: Post, emoji: String) { val target = post.sharedPost ?: post diff --git a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt index 64717636..ed1067d6 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/settings/SettingsScreen.kt @@ -19,6 +19,7 @@ import androidx.compose.material.icons.automirrored.filled.Logout import androidx.compose.material.icons.automirrored.outlined.Article import androidx.compose.material.icons.automirrored.outlined.LibraryBooks import androidx.compose.material.icons.filled.Add +import androidx.compose.material.icons.filled.Bookmark import androidx.compose.material.icons.filled.Delete import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Info @@ -67,6 +68,7 @@ fun SettingsScreen( onProfileClick: (String) -> Unit, onNavigateBack: () -> Unit, onDraftsClick: () -> Unit = {}, + onBookmarksClick: () -> Unit = {}, onLicensesClick: () -> Unit = {}, isLoggedIn: Boolean, viewModel: SettingsViewModel = hiltViewModel() @@ -232,6 +234,28 @@ fun SettingsScreen( ) } + HorizontalDivider(color = colors.divider, thickness = 1.dp) + + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onBookmarksClick() } + .padding(16.dp), + verticalAlignment = Alignment.CenterVertically + ) { + Icon( + imageVector = Icons.Default.Bookmark, + contentDescription = null, + tint = colors.textSecondary + ) + Spacer(modifier = Modifier.width(16.dp)) + Text( + text = stringResource(R.string.bookmarks), + style = typography.bodyLarge, + color = colors.textPrimary + ) + } + if (passkeyEnabled) { HorizontalDivider(color = colors.divider, thickness = 1.dp) diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt index da7bb351..34b76b37 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineScreen.kt @@ -1,6 +1,7 @@ package pub.hackers.android.ui.screens.timeline import android.content.Intent +import android.widget.Toast import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -72,6 +73,8 @@ fun TimelineScreen( val items = viewModel.posts.collectAsLazyPagingItems() val listState = rememberLazyListState() val context = LocalContext.current + val bookmarkedMessage = stringResource(R.string.bookmarked) + val bookmarkRemovedMessage = stringResource(R.string.bookmark_removed) val colors = LocalAppColors.current // Refresh draft count when screen becomes visible (e.g., returning from Drafts) @@ -264,6 +267,19 @@ fun TimelineScreen( onReactionLongPress = { viewModel.showReactionPicker(post.sharedPost?.id ?: post.id) }, + onBookmarkClick = { + val displayPost = post.sharedPost ?: post + Toast.makeText( + context, + if (displayPost.viewerHasBookmarked) { + bookmarkRemovedMessage + } else { + bookmarkedMessage + }, + Toast.LENGTH_SHORT + ).show() + viewModel.toggleBookmark(post) + }, onExternalShareClick = { val displayPost = post.sharedPost ?: post val shareUrl = displayPost.url ?: displayPost.iri diff --git a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt index 91f13429..345626a3 100644 --- a/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt +++ b/app/src/main/java/pub/hackers/android/ui/screens/timeline/TimelineViewModel.kt @@ -26,6 +26,7 @@ import pub.hackers.android.data.paging.personalTimelinePage import pub.hackers.android.data.repository.HackersPubRepository import pub.hackers.android.domain.model.Post import pub.hackers.android.domain.model.ReactionGroup +import pub.hackers.android.ui.bookmark.BookmarkMutationCoordinator import javax.inject.Inject data class TimelineUiState( @@ -45,6 +46,23 @@ class TimelineViewModel @Inject constructor( val uiState: StateFlow = _uiState.asStateFlow() private val overlayStore = PostOverlayStore() + private val bookmarkCoordinator = BookmarkMutationCoordinator( + scope = viewModelScope, + requestMutation = { postId, shouldBookmark -> + if (shouldBookmark) repository.bookmarkPost(postId) + else repository.unbookmarkPost(postId) + }, + applyDesiredState = { postId, isBookmarked -> + overlayStore.mutate(postId) { + it.copy(viewerHasBookmarked = isBookmarked) + } + }, + revertFailedState = { postId, attemptedState -> + overlayStore.mutate(postId) { prev -> + prev.copy(viewerHasBookmarked = !attemptedState) + } + }, + ) private var currentPagingSource: PagingSource? = null @@ -122,6 +140,11 @@ class TimelineViewModel @Inject constructor( toggleReaction(post, "❤️") } + fun toggleBookmark(post: Post) { + val target = post.sharedPost ?: post + bookmarkCoordinator.toggle(target.id, target.viewerHasBookmarked) + } + /** * Optimistically toggle a reaction on [post] (or its sharedPost target). * The overlay is computed from the post's current reactionGroups (possibly diff --git a/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt b/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt index 3302f73c..4b9fa8d8 100644 --- a/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt +++ b/app/src/main/java/pub/hackers/android/ui/theme/Colors.kt @@ -18,6 +18,7 @@ data class AppColorScheme( val composeAccent: Color, val composeOnAccent: Color, val reaction: Color, + val bookmark: Color, val share: Color, val hashtag: Color, ) @@ -35,6 +36,7 @@ val LightAppColors = AppColorScheme( composeAccent = Color(0xFFEF4444), composeOnAccent = Color(0xFFFFFFFF), reaction = Color(0xFFE8453C), + bookmark = Color(0xFFF59E0B), share = Color(0xFF34D399), hashtag = Color(0xFF0891B2), ) @@ -52,6 +54,7 @@ val DarkAppColors = AppColorScheme( composeAccent = Color(0xFFF87171), composeOnAccent = Color(0xFFFFFFFF), reaction = Color(0xFFE8453C), + bookmark = Color(0xFFFBBF24), share = Color(0xFF34D399), hashtag = Color(0xFF22D3EE), ) diff --git a/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt b/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt index 1e07fd51..97d95b58 100644 --- a/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt +++ b/app/src/main/java/pub/hackers/android/ui/theme/Theme.kt @@ -92,6 +92,7 @@ private fun dynamicAppColors(scheme: ColorScheme, dark: Boolean) = AppColorSchem composeAccent = scheme.primaryContainer, composeOnAccent = scheme.onPrimaryContainer, reaction = if (dark) DarkAppColors.reaction else LightAppColors.reaction, + bookmark = if (dark) DarkAppColors.bookmark else LightAppColors.bookmark, share = if (dark) DarkAppColors.share else LightAppColors.share, hashtag = if (dark) DarkAppColors.hashtag else LightAppColors.hashtag, ) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 994613d0..c8630435 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -108,6 +108,17 @@ Article Read full article Read on web + Bookmark + Bookmarks + Bookmarked + Bookmark removed + Failed to bookmark + Failed to remove bookmark + No bookmarks yet + Posts and articles you save will show up here. + All + Articles + Notes Replies Shares Reactions diff --git a/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt b/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt index f12db52b..26eb542d 100644 --- a/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt +++ b/app/src/test/java/pub/hackers/android/ui/screens/postdetail/PostDetailContentTest.kt @@ -158,6 +158,7 @@ class PostDetailContentTest { onReplyClick = {}, onReactionClick = {}, onReactionPickerClick = {}, + onBookmarkClick = {}, onQuoteClick = {}, onSharesClick = {}, onQuotesClick = {},