diff --git a/app/src/main/graphql/pub/hackers/android/operations.graphql b/app/src/main/graphql/pub/hackers/android/operations.graphql index 39609ac..3507333 100644 --- a/app/src/main/graphql/pub/hackers/android/operations.graphql +++ b/app/src/main/graphql/pub/hackers/android/operations.graphql @@ -141,6 +141,35 @@ fragment SharedPostFields on Post { } } +fragment NewsStoryFields on PostLink { + id + uuid + title + description + url + siteName + author + score + postCount + discussionCount + firstSharedAt + latestActivityAt + sourceBreakdown { + local + remote + bluesky + } + image { + url + alt + width + height + } + creator { + ...ActorFields + } +} + query PublicTimeline($first: Int, $after: String, $before: String, $last: Int, $languages: [Locale!]) { publicTimeline(first: $first, after: $after, before: $before, last: $last, languages: $languages) { edges { @@ -209,6 +238,23 @@ query PersonalTimeline($first: Int, $after: String, $before: String, $last: Int, } } +query NewsStories($first: Int, $after: String, $order: NewsOrder) { + newsStories(first: $first, after: $after, order: $order) { + edges { + cursor + node { + ...NewsStoryFields + } + } + pageInfo { + hasNextPage + hasPreviousPage + startCursor + endCursor + } + } +} + query Viewer { viewer { id diff --git a/app/src/main/graphql/pub/hackers/android/schema.graphqls b/app/src/main/graphql/pub/hackers/android/schema.graphqls index 82597cd..a27a794 100644 --- a/app/src/main/graphql/pub/hackers/android/schema.graphqls +++ b/app/src/main/graphql/pub/hackers/android/schema.graphqls @@ -1358,6 +1358,22 @@ type PostEngagementStats { shares: Int! } +enum NewsOrder { + ALL_TIME + + NEWEST + + POPULAR +} + +type NewsSourceBreakdown { + bluesky: Int! + + local: Int! + + remote: Int! +} + type PostLink implements Node { author: String @@ -1365,17 +1381,31 @@ type PostLink implements Node { description: String + discussionCount: Int! + + firstSharedAt: DateTime + id: ID! image: PostLinkImage + latestActivityAt: DateTime + + postCount: Int! + + score: Float! + siteName: String + sourceBreakdown: NewsSourceBreakdown! + title: String type: String url: URL! + + uuid: UUID! } type PostLinkImage { @@ -1545,6 +1575,8 @@ type Query { markdownGuide("The locale for the Markdown guide." locale: Locale!): Document! + newsStories(after: String, before: String, first: Int, last: Int, order: NewsOrder = POPULAR): QueryNewsStoriesConnection! + node(id: ID!): Node nodes(ids: [ID!]!): [Node]! @@ -1589,6 +1621,18 @@ type QueryBookmarksConnectionEdge { node: Post! } +type QueryNewsStoriesConnection { + edges: [QueryNewsStoriesConnectionEdge!]! + + pageInfo: PageInfo! +} + +type QueryNewsStoriesConnectionEdge { + cursor: String! + + node: PostLink! +} + type QueryPersonalTimelineConnection { edges: [QueryPersonalTimelineConnectionEdge!]! 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 dfe8617..fca9923 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 @@ -5,6 +5,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingSource import androidx.paging.PagingState import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.NewsOrder /** * Shape common to every cursor-based paginated endpoint in this codebase. @@ -107,6 +108,12 @@ suspend fun HackersPubRepository.localTimelinePage( ) = getLocalTimeline(after = after, refresh = (after == null), languages = languages) .map { CursorPage(it.posts, it.endCursor, it.hasNextPage, it.startCursor, it.hasPreviousPage) } +suspend fun HackersPubRepository.newsStoriesPage( + after: String?, + order: NewsOrder, +) = getNewsStories(after = after, refresh = (after == null), order = order) + .map { CursorPage(it.stories, it.endCursor, it.hasNextPage, it.startCursor, it.hasPreviousPage) } + suspend fun HackersPubRepository.postRepliesPage(postId: String, after: String?) = getPostReplies(postId, after) .map { CursorPage(it.posts, it.endCursor, it.hasNextPage) } 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 5b2db84..2be264c 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 @@ -48,6 +48,7 @@ import pub.hackers.android.graphql.LoginByUsernameMutation import pub.hackers.android.graphql.MarkNotificationsAsReadMutation import pub.hackers.android.graphql.MediumGeneratedAltTextQuery import pub.hackers.android.graphql.NotificationsQuery +import pub.hackers.android.graphql.NewsStoriesQuery import pub.hackers.android.graphql.PersonalTimelineQuery import pub.hackers.android.graphql.PinPostMutation import pub.hackers.android.graphql.PostQuotesQuery @@ -81,10 +82,12 @@ import pub.hackers.android.graphql.UpdateNoteMutation import pub.hackers.android.graphql.ViewerQuery import pub.hackers.android.graphql.type.AccountLinkInput import pub.hackers.android.graphql.type.CreateNoteMediumInput +import pub.hackers.android.graphql.type.NewsOrder as GqlNewsOrder import pub.hackers.android.graphql.type.UpdateAccountInput import pub.hackers.android.graphql.fragment.ActorFields import pub.hackers.android.graphql.fragment.EngagementStatsFields import pub.hackers.android.graphql.fragment.MediaFields +import pub.hackers.android.graphql.fragment.NewsStoryFields import pub.hackers.android.graphql.fragment.PostFields import pub.hackers.android.graphql.fragment.SharedPostFields import pub.hackers.android.graphql.type.PostVisibility as GqlPostVisibility @@ -237,6 +240,45 @@ class HackersPubRepository @Inject constructor( } } + suspend fun getNewsStories( + after: String? = null, + refresh: Boolean = false, + order: NewsOrder = NewsOrder.POPULAR, + ): Result { + return try { + val response = apolloClient.query( + NewsStoriesQuery( + first = Optional.present(20), + after = Optional.presentIfNotNull(after), + order = Optional.present(order.toGqlNewsOrder()), + ) + ) + .apply { if (refresh) fetchPolicy(FetchPolicy.NetworkOnly) } + .execute() + + if (response.hasErrors()) { + Result.failure(Exception(response.errors?.firstOrNull()?.message ?: "Unknown error")) + } else { + val data = response.data?.newsStories + withContext(Dispatchers.Default) { + Result.success( + NewsStoriesResult( + stories = data?.edges?.map { edge -> + edge.node.newsStoryFields.toNewsStory() + }?.distinctBy { it.id } ?: emptyList(), + hasNextPage = data?.pageInfo?.hasNextPage ?: false, + endCursor = data?.pageInfo?.endCursor, + hasPreviousPage = data?.pageInfo?.hasPreviousPage ?: false, + startCursor = data?.pageInfo?.startCursor, + ) + ) + } + } + } catch (e: Exception) { + Result.failure(e) + } + } + suspend fun getSuggestedFilterLanguages(): Result> { return try { val response = apolloClient.query(SuggestedFilterLanguagesQuery()).execute() @@ -1796,6 +1838,37 @@ class HackersPubRepository @Inject constructor( } // Extension functions to convert GraphQL fragment types to domain models + private fun NewsStoryFields.toNewsStory(): NewsStory { + return NewsStory( + id = id, + uuid = uuid.toString(), + title = title, + description = description, + url = url.toString(), + siteName = siteName, + author = author, + image = image?.let { img -> + PostLinkImage( + url = img.url.toString(), + alt = img.alt, + width = img.width, + height = img.height, + ) + }, + creator = creator?.actorFields?.toActor(), + score = score, + postCount = postCount, + discussionCount = discussionCount, + firstSharedAt = firstSharedAt?.let { Instant.parse(it.toString()) }, + latestActivityAt = latestActivityAt?.let { Instant.parse(it.toString()) }, + sourceBreakdown = NewsSourceBreakdown( + local = sourceBreakdown.local, + remote = sourceBreakdown.remote, + bluesky = sourceBreakdown.bluesky, + ), + ) + } + private fun PostFields.toPost( sharedPost: Post? = null, replyTarget: Post? = null, @@ -1911,6 +1984,14 @@ class HackersPubRepository @Inject constructor( } } + private fun NewsOrder.toGqlNewsOrder(): GqlNewsOrder { + return when (this) { + NewsOrder.POPULAR -> GqlNewsOrder.POPULAR + NewsOrder.NEWEST -> GqlNewsOrder.NEWEST + NewsOrder.ALL_TIME -> GqlNewsOrder.ALL_TIME + } + } + private fun ActorFields.toActor(): Actor { return Actor( id = id, 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 36bdc15..09e59ed 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 @@ -82,6 +82,36 @@ data class PostLink( val creator: Actor? ) +enum class NewsOrder { + POPULAR, NEWEST, ALL_TIME +} + +@Immutable +data class NewsSourceBreakdown( + val local: Int, + val remote: Int, + val bluesky: Int +) + +@Immutable +data class NewsStory( + val id: String, + val uuid: String, + val title: String?, + val description: String?, + val url: String, + val siteName: String?, + val author: String?, + val image: PostLinkImage?, + val creator: Actor?, + val score: Double, + val postCount: Int, + val discussionCount: Int, + val firstSharedAt: Instant?, + val latestActivityAt: Instant?, + val sourceBreakdown: NewsSourceBreakdown +) + @Immutable data class Post( val id: String, @@ -278,6 +308,15 @@ data class TimelineResult( val startCursor: String? = null, ) +@Immutable +data class NewsStoriesResult( + val stories: List, + val hasNextPage: Boolean, + val endCursor: String?, + val hasPreviousPage: Boolean = false, + val startCursor: String? = null, +) + @Immutable data class NotificationsResult( val notifications: List, 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 85fa075..c46bb95 100644 --- a/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt +++ b/app/src/main/java/pub/hackers/android/ui/HackersPubApp.kt @@ -44,6 +44,7 @@ import pub.hackers.android.ui.screens.compose.ComposeArticleScreen import pub.hackers.android.ui.screens.compose.ComposeScreen import pub.hackers.android.ui.screens.drafts.DraftsScreen import pub.hackers.android.ui.screens.explore.ExploreScreen +import pub.hackers.android.ui.screens.news.NewsScreen import pub.hackers.android.ui.screens.notifications.NotificationsScreen import pub.hackers.android.ui.screens.postdetail.PostByUrlResolverScreen import pub.hackers.android.ui.screens.postdetail.PostDetailScreen @@ -73,6 +74,13 @@ sealed class Screen( Icons.Outlined.Home, requiresAuth = true ) + data object News : Screen( + "news", + R.string.nav_news, + Icons.Filled.Home, + Icons.Outlined.Home, + requiresAuth = true + ) data object Notifications : Screen( "notifications", R.string.nav_notifications, @@ -175,6 +183,7 @@ fun HackersPubApp( val fontSizePercent by viewModel.preferencesManager.fontSizePercent.collectAsState(initial = 100) val hasUnread by viewModel.hasUnread.collectAsState() + var selectedHomeFeed by remember { mutableStateOf(HomeFeed.TIMELINE) } // Wait for auth state to resolve from DataStore before rendering navigation. // This prevents NavHost graph recreation when isLoggedIn transitions from the @@ -239,7 +248,7 @@ fun HackersPubApp( val bottomNavItems = if (isLoggedIn) { listOf( BottomNavItem( - route = Screen.Timeline.route, + route = selectedHomeFeed.route, label = stringResource(R.string.nav_timeline), icon = Icons.Outlined.Home, selectedIcon = Icons.Filled.Home, @@ -291,20 +300,43 @@ fun HackersPubApp( val currentDestination = navBackStackEntry?.destination val currentRoute = currentDestination?.route val currentBaseRoute = currentRoute?.substringBefore('?') - val showBottomBar = bottomNavItems.any { it.route == currentBaseRoute } + val currentHomeFeed = HomeFeed.fromRoute(currentBaseRoute) + LaunchedEffect(currentHomeFeed) { + if (currentHomeFeed != null) { + selectedHomeFeed = currentHomeFeed + } + } + val selectedBottomRoute = currentHomeFeed?.route ?: (currentBaseRoute ?: "") + val showBottomBar = bottomNavItems.any { it.route == currentBaseRoute } || + (isLoggedIn && currentHomeFeed != null) + + fun navigateHomeFeed(feed: HomeFeed) { + selectedHomeFeed = feed + navController.navigate(feed.route) { + popUpTo(navController.graph.findStartDestination().id) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } Scaffold( bottomBar = { if (showBottomBar) { BottomNavBar( items = bottomNavItems, - selectedRoute = currentBaseRoute ?: "", + selectedRoute = selectedBottomRoute, onItemSelected = { item -> + val isHomeItem = HomeFeed.fromRoute(item.route) != null + val isCurrentHome = currentHomeFeed != null if (item.route == (currentBaseRoute ?: "")) { // Re-tap on current tab: signal scroll-to-top / refresh navController.currentBackStackEntry ?.savedStateHandle ?.set("tabRetapped", System.currentTimeMillis()) + } else if (isHomeItem && isCurrentHome) { + navigateHomeFeed(selectedHomeFeed) } else { navController.navigate(item.route) { popUpTo(navController.graph.findStartDestination().id) { @@ -332,6 +364,8 @@ fun HackersPubApp( .collectAsState() TimelineScreen( tabRetapped = tabRetapped, + selectedHomeFeed = selectedHomeFeed, + onHomeFeedSelected = ::navigateHomeFeed, onPostClick = { postId -> navController.navigate(DetailScreen.PostDetail.createRoute(postId)) }, @@ -362,6 +396,16 @@ fun HackersPubApp( ) } + composable(Screen.News.route) { + NewsScreen( + selectedHomeFeed = selectedHomeFeed, + onHomeFeedSelected = ::navigateHomeFeed, + onSettingsClick = { + navController.navigate(Screen.Settings.route) + }, + ) + } + composable(Screen.Notifications.route) { NotificationsScreen( onPostClick = { postId -> diff --git a/app/src/main/java/pub/hackers/android/ui/HomeFeed.kt b/app/src/main/java/pub/hackers/android/ui/HomeFeed.kt new file mode 100644 index 0000000..ac683e6 --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/HomeFeed.kt @@ -0,0 +1,22 @@ +package pub.hackers.android.ui + +import androidx.annotation.StringRes +import pub.hackers.android.R + +enum class HomeFeed( + val route: String, + @param:StringRes val labelResId: Int, +) { + TIMELINE("timeline", R.string.nav_timeline), + NEWS("news", R.string.nav_news); + + companion object { + fun fromRoute(route: String?): HomeFeed? { + return when (route?.substringBefore('?')) { + TIMELINE.route -> TIMELINE + NEWS.route -> NEWS + else -> null + } + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/components/HomeFeedSelector.kt b/app/src/main/java/pub/hackers/android/ui/components/HomeFeedSelector.kt new file mode 100644 index 0000000..269041f --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/components/HomeFeedSelector.kt @@ -0,0 +1,206 @@ +package pub.hackers.android.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.defaultMinSize +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.layout.widthIn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material3.Icon +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Popup +import androidx.compose.ui.window.PopupProperties +import pub.hackers.android.R +import pub.hackers.android.ui.HomeFeed +import pub.hackers.android.ui.theme.AppShapes +import pub.hackers.android.ui.theme.LocalAppColors +import pub.hackers.android.ui.theme.LocalAppTypography + +@Composable +fun HomeFeedSelector( + selectedFeed: HomeFeed, + onFeedSelected: (HomeFeed) -> Unit, + modifier: Modifier = Modifier, +) { + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + val density = LocalDensity.current + var expanded by remember { mutableStateOf(false) } + var anchorWidthPx by remember { mutableIntStateOf(0) } + var anchorHeightPx by remember { mutableIntStateOf(0) } + val selectedLabel = stringResource(selectedFeed.labelResId) + val stateLabel = stringResource( + if (expanded) { + R.string.home_feed_selector_expanded + } else { + R.string.home_feed_selector_collapsed + } + ) + val rotation by animateFloatAsState( + targetValue = if (expanded) 180f else 0f, + label = "homeFeedSelectorChevron", + ) + val triggerColor by animateColorAsState( + targetValue = if (expanded) colors.accentMuted.copy(alpha = 0.16f) else colors.surface, + label = "homeFeedSelectorTrigger", + ) + + Box(modifier = modifier) { + Surface( + modifier = Modifier + .height(AppShapes.pillHeight) + .clip(RoundedCornerShape(AppShapes.pillRadius)) + .onSizeChanged { + anchorWidthPx = it.width + anchorHeightPx = it.height + } + .clickable { expanded = !expanded } + .semantics { + role = Role.Button + contentDescription = selectedLabel + stateDescription = stateLabel + }, + shape = RoundedCornerShape(AppShapes.pillRadius), + color = triggerColor, + border = BorderStroke(1.dp, colors.divider), + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = selectedLabel, + style = typography.bodyLargeSemiBold, + color = colors.textPrimary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.width(6.dp)) + Icon( + imageVector = Icons.Filled.KeyboardArrowDown, + contentDescription = null, + tint = colors.textSecondary, + modifier = Modifier + .size(18.dp) + .rotate(rotation), + ) + } + } + + if (expanded) { + Popup( + alignment = Alignment.TopStart, + offset = IntOffset( + x = 0, + y = anchorHeightPx + with(density) { 6.dp.roundToPx() }, + ), + onDismissRequest = { expanded = false }, + properties = PopupProperties(focusable = true), + ) { + Surface( + shape = RoundedCornerShape(AppShapes.pillRadius), + color = colors.surface, + border = BorderStroke(1.dp, colors.divider), + shadowElevation = 8.dp, + modifier = Modifier.widthIn( + min = with(density) { anchorWidthPx.toDp() }, + ), + ) { + Row( + modifier = Modifier.padding(4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + HomeFeed.entries.forEach { feed -> + HomeFeedSegment( + feed = feed, + selected = feed == selectedFeed, + onClick = { + expanded = false + onFeedSelected(feed) + }, + ) + } + } + } + } + } + } +} + +@Composable +private fun HomeFeedSegment( + feed: HomeFeed, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + val containerColor by animateColorAsState( + targetValue = if (selected) colors.accentMuted.copy(alpha = 0.2f) else colors.surface, + label = "homeFeedChipContainer", + ) + val labelColor by animateColorAsState( + targetValue = if (selected) colors.accent else colors.textSecondary, + label = "homeFeedSegmentLabel", + ) + + Surface( + shape = RoundedCornerShape(AppShapes.pillRadius), + color = containerColor, + modifier = Modifier + .defaultMinSize(minHeight = 32.dp) + .widthIn(min = 72.dp) + .clip(RoundedCornerShape(AppShapes.pillRadius)) + .clickable(onClick = onClick) + .semantics { + role = Role.Button + this.selected = selected + }, + ) { + Row( + modifier = Modifier.padding(horizontal = 12.dp, vertical = 7.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = stringResource(feed.labelResId), + style = typography.labelMedium, + color = labelColor, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/components/LargeTitleHeader.kt b/app/src/main/java/pub/hackers/android/ui/components/LargeTitleHeader.kt index ca07d96..728642b 100644 --- a/app/src/main/java/pub/hackers/android/ui/components/LargeTitleHeader.kt +++ b/app/src/main/java/pub/hackers/android/ui/components/LargeTitleHeader.kt @@ -1,6 +1,7 @@ package pub.hackers.android.ui.components import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.Spacer @@ -19,26 +20,40 @@ fun LargeTitleHeader( title: String, modifier: Modifier = Modifier, leadingContent: @Composable (() -> Unit)? = null, + titleContent: (@Composable () -> Unit)? = null, trailingContent: @Composable (RowScope.() -> Unit)? = null, ) { val colors = LocalAppColors.current val typography = LocalAppTypography.current + val verticalPadding = if (titleContent != null) 8.dp else 12.dp Row( modifier = modifier .fillMaxWidth() - .padding(horizontal = if (leadingContent != null) 4.dp else 16.dp, vertical = 12.dp), + .padding( + horizontal = if (leadingContent != null) 4.dp else 16.dp, + vertical = verticalPadding, + ), verticalAlignment = Alignment.CenterVertically, ) { if (leadingContent != null) { leadingContent() } - Text( - text = title, - style = typography.titleLarge, - color = colors.textPrimary, - modifier = if (leadingContent != null) Modifier.padding(start = 4.dp) else Modifier, - ) + if (titleContent != null) { + Box( + modifier = Modifier.align(Alignment.CenterVertically), + contentAlignment = Alignment.Center, + ) { + titleContent() + } + } else { + Text( + text = title, + style = typography.titleLarge, + color = colors.textPrimary, + modifier = if (leadingContent != null) Modifier.padding(start = 4.dp) else Modifier, + ) + } Spacer(modifier = Modifier.weight(1f)) if (trailingContent != null) { Row( diff --git a/app/src/main/java/pub/hackers/android/ui/screens/news/NewsScreen.kt b/app/src/main/java/pub/hackers/android/ui/screens/news/NewsScreen.kt new file mode 100644 index 0000000..d69392c --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/news/NewsScreen.kt @@ -0,0 +1,373 @@ +package pub.hackers.android.ui.screens.news + +import androidx.compose.foundation.border +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +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.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Settings +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +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.layout.ContentScale +import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +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 coil3.compose.AsyncImage +import pub.hackers.android.R +import pub.hackers.android.domain.model.NewsOrder +import pub.hackers.android.domain.model.NewsStory +import pub.hackers.android.ui.HomeFeed +import pub.hackers.android.ui.components.ErrorMessage +import pub.hackers.android.ui.components.FullScreenLoading +import pub.hackers.android.ui.components.HomeFeedSelector +import pub.hackers.android.ui.components.LargeTitleHeader +import pub.hackers.android.ui.components.LoadingItem +import pub.hackers.android.ui.components.RichDisplayName +import pub.hackers.android.ui.components.formatRelativeTime +import pub.hackers.android.ui.theme.AppShapes +import pub.hackers.android.ui.theme.LocalAppColors +import pub.hackers.android.ui.theme.LocalAppTypography +import java.net.URI +import java.util.Locale + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun NewsScreen( + selectedHomeFeed: HomeFeed, + onHomeFeedSelected: (HomeFeed) -> Unit, + onSettingsClick: () -> Unit, + modifier: Modifier = Modifier, + viewModel: NewsViewModel = hiltViewModel(), +) { + val stories = viewModel.stories.collectAsLazyPagingItems() + val uiState by viewModel.uiState.collectAsState() + val listState = rememberLazyListState() + val colors = LocalAppColors.current + + LaunchedEffect(uiState.selectedOrder) { + listState.scrollToItem(0) + } + + Scaffold( + contentWindowInsets = WindowInsets(0), + topBar = { + LargeTitleHeader( + title = stringResource(R.string.personal_timeline), + titleContent = { + HomeFeedSelector( + selectedFeed = selectedHomeFeed, + onFeedSelected = onHomeFeedSelected, + ) + }, + ) { + Box( + modifier = Modifier + .size(28.dp) + .background(color = colors.surface, shape = CircleShape) + .clickable { onSettingsClick() }, + contentAlignment = Alignment.Center, + ) { + Icon( + imageVector = Icons.Outlined.Settings, + contentDescription = stringResource(R.string.nav_settings), + tint = colors.accent, + modifier = Modifier.size(22.dp), + ) + } + } + }, + modifier = modifier, + ) { paddingValues -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(paddingValues), + ) { + NewsOrderRow( + selectedOrder = uiState.selectedOrder, + onOrderSelected = viewModel::selectOrder, + ) + + HorizontalDivider(color = colors.divider) + + Box(modifier = Modifier.fillMaxSize()) { + val refresh = stories.loadState.refresh + when { + refresh is LoadState.Error && stories.itemCount == 0 -> { + ErrorMessage( + message = refresh.error.message ?: stringResource(R.string.error_generic), + onRetry = { stories.refresh() }, + ) + } + + stories.itemCount == 0 && + refresh is LoadState.NotLoading && + refresh.endOfPaginationReached -> { + ErrorMessage( + message = stringResource(R.string.news_empty), + onRefresh = { stories.refresh() }, + ) + } + + stories.itemCount == 0 -> { + FullScreenLoading() + } + + else -> { + PullToRefreshBox( + isRefreshing = refresh is LoadState.Loading, + onRefresh = { stories.refresh() }, + ) { + LazyColumn(state = listState) { + items( + count = stories.itemCount, + key = stories.itemKey { it.id }, + ) { index -> + val story = stories[index] ?: return@items + NewsStoryCard( + story = story, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } + + if (stories.loadState.append is LoadState.Loading) { + item { LoadingItem() } + } + } + } + } + } + } + } + } +} + +@Composable +private fun NewsOrderRow( + selectedOrder: NewsOrder, + onOrderSelected: (NewsOrder) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 10.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + NewsOrder.entries.forEach { order -> + NewsOrderChip( + label = stringResource(order.labelResId()), + selected = order == selectedOrder, + onClick = { onOrderSelected(order) }, + ) + } + } +} + +@Composable +private fun NewsOrderChip( + label: String, + selected: Boolean, + onClick: () -> Unit, +) { + val colors = LocalAppColors.current + FilterChip( + selected = selected, + onClick = onClick, + label = { + Text( + text = label, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + }, + colors = FilterChipDefaults.filterChipColors( + containerColor = colors.background, + labelColor = colors.textBody, + selectedContainerColor = colors.accent, + selectedLabelColor = colors.background, + ), + border = FilterChipDefaults.filterChipBorder( + enabled = true, + selected = selected, + borderColor = colors.buttonOutline, + selectedBorderColor = colors.accent, + ), + ) +} + +@Composable +private fun NewsStoryCard( + story: NewsStory, + modifier: Modifier = Modifier, +) { + val colors = LocalAppColors.current + val typography = LocalAppTypography.current + val uriHandler = LocalUriHandler.current + val domain = remember(story.url) { story.url.displayHost() } + val activityTime = remember(story.latestActivityAt, story.firstSharedAt) { + (story.latestActivityAt ?: story.firstSharedAt)?.let { formatRelativeTime(it) } + } + + Column( + modifier = modifier + .fillMaxWidth() + .border( + width = 1.dp, + color = colors.divider, + shape = RoundedCornerShape(AppShapes.mediaRadius), + ) + .clip(RoundedCornerShape(AppShapes.mediaRadius)) + .clickable { uriHandler.openUri(story.url) } + .padding(12.dp), + ) { + Row(verticalAlignment = Alignment.Top) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = story.title.takeUnless { it.isNullOrBlank() } ?: domain, + style = typography.titleMedium, + color = colors.textPrimary, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + Spacer(modifier = Modifier.height(4.dp)) + Text( + text = domain, + style = typography.labelMedium, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + val image = story.image + if (image != null) { + Spacer(modifier = Modifier.width(12.dp)) + AsyncImage( + model = image.url, + contentDescription = image.alt, + modifier = Modifier + .size(72.dp) + .clip(RoundedCornerShape(6.dp)), + contentScale = ContentScale.Crop, + ) + } + } + + if (!story.description.isNullOrBlank()) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + text = story.description, + style = typography.bodyMedium, + color = colors.textSecondary, + maxLines = 3, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(modifier = Modifier.height(10.dp)) + + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = story.metaLabel(activityTime), + style = typography.labelMedium, + color = colors.textSecondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f), + ) + + Text( + text = stringResource(R.string.news_discussion_count, story.discussionCount), + style = typography.labelMedium.copy(fontWeight = FontWeight.SemiBold), + color = colors.accent, + maxLines = 1, + ) + } + + val creator = story.creator + if (creator != null) { + Spacer(modifier = Modifier.height(8.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + AsyncImage( + model = creator.avatarUrl, + contentDescription = null, + modifier = Modifier + .size(18.dp) + .clip(CircleShape), + contentScale = ContentScale.Crop, + ) + Spacer(modifier = Modifier.width(6.dp)) + RichDisplayName( + name = creator.name, + fallback = creator.handle, + style = typography.labelMedium, + color = colors.textSecondary, + emojiHeight = 14.dp, + ) + } + } + } +} + +private fun NewsOrder.labelResId(): Int { + return when (this) { + NewsOrder.POPULAR -> R.string.news_order_popular + NewsOrder.NEWEST -> R.string.news_order_newest + NewsOrder.ALL_TIME -> R.string.news_order_all_time + } +} + +private fun NewsStory.metaLabel(activityTime: String?): String { + val parts = mutableListOf() + parts += String.format(Locale.ROOT, "%.1f", score) + parts += "$postCount posts" + if (activityTime != null) parts += activityTime + return parts.joinToString(" ยท ") +} + +private fun String.displayHost(): String { + return try { + URI(this).host?.removePrefix("www.") ?: this + } catch (_: Exception) { + this + } +} diff --git a/app/src/main/java/pub/hackers/android/ui/screens/news/NewsViewModel.kt b/app/src/main/java/pub/hackers/android/ui/screens/news/NewsViewModel.kt new file mode 100644 index 0000000..39c1dbe --- /dev/null +++ b/app/src/main/java/pub/hackers/android/ui/screens/news/NewsViewModel.kt @@ -0,0 +1,48 @@ +package pub.hackers.android.ui.screens.news + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import androidx.paging.PagingData +import androidx.paging.cachedIn +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.flatMapLatest +import kotlinx.coroutines.flow.update +import pub.hackers.android.data.paging.cursorPager +import pub.hackers.android.data.paging.newsStoriesPage +import pub.hackers.android.data.repository.HackersPubRepository +import pub.hackers.android.domain.model.NewsOrder +import pub.hackers.android.domain.model.NewsStory +import javax.inject.Inject + +data class NewsUiState( + val selectedOrder: NewsOrder = NewsOrder.POPULAR, +) + +@OptIn(ExperimentalCoroutinesApi::class) +@HiltViewModel +class NewsViewModel @Inject constructor( + private val repository: HackersPubRepository, +) : ViewModel() { + + private val selectedOrder = MutableStateFlow(NewsOrder.POPULAR) + + private val _uiState = MutableStateFlow(NewsUiState()) + val uiState: StateFlow = _uiState.asStateFlow() + + val stories: Flow> = selectedOrder.flatMapLatest { order -> + cursorPager { after -> + repository.newsStoriesPage(after, order) + }.flow.cachedIn(viewModelScope) + }.cachedIn(viewModelScope) + + fun selectOrder(order: NewsOrder) { + if (selectedOrder.value == order) return + selectedOrder.value = order + _uiState.update { it.copy(selectedOrder = order) } + } +} 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 ff2234b..cffe053 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 @@ -47,8 +47,10 @@ import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.itemKey import coil3.compose.AsyncImage import pub.hackers.android.R +import pub.hackers.android.ui.HomeFeed import pub.hackers.android.ui.components.ErrorMessage import pub.hackers.android.ui.components.FullScreenLoading +import pub.hackers.android.ui.components.HomeFeedSelector import pub.hackers.android.ui.components.LargeTitleHeader import pub.hackers.android.ui.components.LanguageFilterRow import pub.hackers.android.ui.components.LoadingItem @@ -68,6 +70,8 @@ fun TimelineScreen( onRecommendedActorsClick: () -> Unit = {}, onComposeArticleClick: () -> Unit = {}, onComposeArticleLongClick: () -> Unit = {}, + selectedHomeFeed: HomeFeed = HomeFeed.TIMELINE, + onHomeFeedSelected: (HomeFeed) -> Unit = {}, tabRetapped: Long = 0L, userAvatarUrl: String? = null, viewModel: TimelineViewModel = hiltViewModel() @@ -131,7 +135,15 @@ fun TimelineScreen( Scaffold( contentWindowInsets = WindowInsets(0), topBar = { - LargeTitleHeader(title = stringResource(R.string.personal_timeline)) { + LargeTitleHeader( + title = stringResource(R.string.personal_timeline), + titleContent = { + HomeFeedSelector( + selectedFeed = selectedHomeFeed, + onFeedSelected = onHomeFeedSelected, + ) + }, + ) { // New article button with draft badge Box( modifier = Modifier diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 548daff..ac9006c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,6 +2,7 @@ Timeline + News Notifications Explore Search @@ -34,6 +35,13 @@ Hackers\' Pub Fediverse Home + Home feed selector expanded + Home feed selector collapsed + Popular + Newest + All-time + %1$d comments + No news stories yet All languages No posts yet Load more diff --git a/app/src/test/java/pub/hackers/android/ui/HomeFeedTest.kt b/app/src/test/java/pub/hackers/android/ui/HomeFeedTest.kt new file mode 100644 index 0000000..d1e2f4a --- /dev/null +++ b/app/src/test/java/pub/hackers/android/ui/HomeFeedTest.kt @@ -0,0 +1,26 @@ +package pub.hackers.android.ui + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class HomeFeedTest { + + @Test + fun `fromRoute resolves home feed routes`() { + assertEquals(HomeFeed.TIMELINE, HomeFeed.fromRoute("timeline")) + assertEquals(HomeFeed.NEWS, HomeFeed.fromRoute("news")) + } + + @Test + fun `fromRoute ignores query parameters`() { + assertEquals(HomeFeed.TIMELINE, HomeFeed.fromRoute("timeline?foo=bar")) + assertEquals(HomeFeed.NEWS, HomeFeed.fromRoute("news?order=NEWEST")) + } + + @Test + fun `fromRoute returns null for unknown routes`() { + assertNull(HomeFeed.fromRoute(null)) + assertNull(HomeFeed.fromRoute("explore")) + } +} diff --git a/app/src/test/java/pub/hackers/android/ui/components/HomeFeedSelectorTest.kt b/app/src/test/java/pub/hackers/android/ui/components/HomeFeedSelectorTest.kt new file mode 100644 index 0000000..7dce58f --- /dev/null +++ b/app/src/test/java/pub/hackers/android/ui/components/HomeFeedSelectorTest.kt @@ -0,0 +1,60 @@ +package pub.hackers.android.ui.components + +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import pub.hackers.android.ui.HomeFeed +import pub.hackers.android.ui.theme.AppTypographyDefaults +import pub.hackers.android.ui.theme.LightAppColors +import pub.hackers.android.ui.theme.LocalAppColors +import pub.hackers.android.ui.theme.LocalAppTypography + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class HomeFeedSelectorTest { + + @get:Rule + val composeRule = createComposeRule() + + @Test + fun `selector shows chips and selects news`() { + composeRule.setContent { + TestTheme { + var selectedFeed by remember { mutableStateOf(HomeFeed.TIMELINE) } + HomeFeedSelector( + selectedFeed = selectedFeed, + onFeedSelected = { selectedFeed = it }, + ) + } + } + + composeRule.onNodeWithText("Timeline").assertIsDisplayed().performClick() + composeRule.onAllNodesWithText("News")[0].assertIsDisplayed().performClick() + + composeRule.onNodeWithText("News").assertIsDisplayed() + } +} + +@Composable +private fun TestTheme(content: @Composable () -> Unit) { + CompositionLocalProvider( + LocalAppColors provides LightAppColors, + LocalAppTypography provides AppTypographyDefaults, + ) { + MaterialTheme(content = content) + } +}