Add tablet specific UI (#686)

Hi @msfjarvis! Please have a look at what I have been able to implement
so far. When trying to call the ListDetail version in the MainActivity I
realized that the MainActivity is based on the BaseActivity class, and I
wasn't exactly sure how to set the parameters for ListDetail. Let me
know your thoughts when you get a chance. Thanks!

---------

Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
ThanaReka 2024-10-26 16:53:43 -04:00 committed by GitHub
parent f215210ffa
commit b84f266db7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 360 additions and 16 deletions

View file

@ -90,6 +90,9 @@ dependencies {
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.window.size)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.material3.adaptive.layout)
implementation(libs.androidx.compose.material3.adaptive.navigation)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui)
implementation(libs.androidx.compose.ui.text)

View file

@ -6,12 +6,16 @@
*/
package dev.msfjarvis.claw.android
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi
import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass
import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
import dev.msfjarvis.claw.android.ui.screens.LobstersPostsScreen
import dev.msfjarvis.claw.android.ui.screens.TwoPaneLayout
@ContributesActivityInjector
class MainActivity : BaseActivity() {
@ -20,12 +24,25 @@ class MainActivity : BaseActivity() {
@Composable
override fun Content() {
val windowSizeClass = calculateWindowSizeClass(this)
LobstersPostsScreen(
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url },
)
when (windowSizeClass.widthSizeClass) {
WindowWidthSizeClass.Compact -> {
LobstersPostsScreen(
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
windowSizeClass = windowSizeClass,
setWebUri = { url -> webUri = url },
)
}
else -> {
TwoPaneLayout(
urlLauncher = urlLauncher,
htmlConverter = htmlConverter,
modifier = Modifier.fillMaxSize(),
)
}
}
}
override fun preLaunch() {

View file

@ -80,6 +80,57 @@ fun PostActions(
}
}
fun TwoPaneLayoutPostActions(
context: Context,
urlLauncher: UrlLauncher,
viewModel: ClawViewModel,
setSelectedPost: (String) -> Unit,
): PostActions {
return object : PostActions {
override fun viewPost(postId: String, postUrl: String, commentsUrl: String) {
viewModel.markPostAsRead(postId)
urlLauncher.openUri(postUrl.ifEmpty { commentsUrl })
}
override fun viewComments(postId: String) {
viewModel.markPostAsRead(postId)
setSelectedPost(postId) // Update selectedPostId
}
override fun viewCommentsPage(post: UIPost) {
urlLauncher.openUri(post.commentsUrl)
}
override fun toggleSave(post: UIPost) {
viewModel.toggleSave(post)
}
override fun share(post: UIPost) {
val sendIntent =
Intent().apply {
action = Intent.ACTION_SEND
putExtra(Intent.EXTRA_TEXT, post.url.ifEmpty { post.commentsUrl })
putExtra(Intent.EXTRA_TITLE, post.title)
type = "text/plain"
}
val shareIntent = Intent.createChooser(sendIntent, null)
context.startActivity(shareIntent)
}
override fun isPostRead(post: UIPost): Boolean = viewModel.isPostRead(post)
override fun isPostSaved(post: UIPost): Boolean = viewModel.isPostSaved(post)
override suspend fun getComments(postId: String): UIPost {
return viewModel.getPostComments(postId)
}
override suspend fun getLinkMetadata(url: String): LinkMetadata {
return viewModel.getLinkMetadata(url)
}
}
}
/**
* Convert an [ApiResult.Failure.HttpFailure] to a scoped down error with a more useful user-facing
* message.

View file

@ -7,6 +7,7 @@
package dev.msfjarvis.claw.android.ui.lists
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
@ -50,6 +51,7 @@ fun NetworkPosts(
postActions: PostActions,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
onPostClick: (String) -> Unit,
) {
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
val refreshLoadState by rememberUpdatedState(lazyPagingItems.loadState.refresh)
@ -82,7 +84,14 @@ fun NetworkPosts(
) { index ->
val item = lazyPagingItems[index]
if (item != null) {
LobstersListItem(item = item, postActions = postActions)
LobstersListItem(
item = item,
postActions = postActions,
modifier =
Modifier.clickable {
onPostClick(item.shortId) // Trigger the click listener
},
)
HorizontalDivider()
}
}
@ -112,6 +121,7 @@ private fun ListPreview() {
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
contentPadding = PaddingValues(),
onPostClick = {},
)
}
}

View file

@ -0,0 +1,127 @@
/*
* Copyright © 2021-2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.lists
import androidx.activity.compose.ReportDrawnWhen
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.wrapContentWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.material3.pulltorefresh.PullToRefreshDefaults
import androidx.compose.material3.pulltorefresh.rememberPullToRefreshState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.paging.LoadState
import androidx.paging.PagingData
import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.collectAsLazyPagingItems
import androidx.paging.compose.itemContentType
import androidx.paging.compose.itemKey
import dev.msfjarvis.claw.common.posts.PostActions
import dev.msfjarvis.claw.common.posts.TEST_POST
import dev.msfjarvis.claw.common.posts.TEST_POST_ACTIONS
import dev.msfjarvis.claw.common.theme.LobstersTheme
import dev.msfjarvis.claw.common.ui.NetworkError
import dev.msfjarvis.claw.common.ui.ProgressBar
import dev.msfjarvis.claw.common.ui.preview.DevicePreviews
import dev.msfjarvis.claw.model.UIPost
import kotlinx.coroutines.flow.MutableStateFlow
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun NetworkPostsForTwoPaneLayout(
lazyPagingItems: LazyPagingItems<UIPost>,
listState: LazyListState,
postActions: PostActions,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
onPostClick: (String) -> Unit,
) {
ReportDrawnWhen { lazyPagingItems.itemCount > 0 }
val refreshLoadState by rememberUpdatedState(lazyPagingItems.loadState.refresh)
val state = rememberPullToRefreshState()
PullToRefreshBox(
modifier = modifier.fillMaxSize(),
isRefreshing = refreshLoadState == LoadState.Loading,
state = state,
onRefresh = { lazyPagingItems.refresh() },
indicator = {
PullToRefreshDefaults.Indicator(
state = state,
isRefreshing = refreshLoadState == LoadState.Loading,
modifier = Modifier.align(Alignment.TopCenter).padding(contentPadding),
)
},
) {
if (lazyPagingItems.itemCount == 0 && refreshLoadState is LoadState.Error) {
NetworkError(
label = "Failed to load posts",
error = (refreshLoadState as LoadState.Error).error,
modifier = Modifier.align(Alignment.Center),
)
} else {
LazyColumn(contentPadding = contentPadding, state = listState) {
items(
count = lazyPagingItems.itemCount,
key = lazyPagingItems.itemKey { it.shortId },
contentType = lazyPagingItems.itemContentType { "LobstersItem" },
) { index ->
val item = lazyPagingItems[index]
if (item != null) {
LobstersListItem(
item = item,
postActions = postActions,
modifier =
Modifier.clickable {
onPostClick(item.shortId) // Trigger the click listener)
},
)
HorizontalDivider()
}
}
if (lazyPagingItems.loadState.append == LoadState.Loading) {
item(key = "progressbar") {
ProgressBar(
modifier =
Modifier.fillMaxWidth()
.wrapContentWidth(Alignment.CenterHorizontally)
.padding(vertical = 16.dp)
)
}
}
}
}
}
}
@DevicePreviews
@Composable
private fun ListPreview() {
val items = List(20) { TEST_POST.copy(shortId = "${TEST_POST.shortId}${it}") }
val flow = MutableStateFlow(PagingData.from(items))
LobstersTheme {
NetworkPostsForTwoPaneLayout(
lazyPagingItems = flow.collectAsLazyPagingItems(),
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
contentPadding = PaddingValues(),
onPostClick = {},
)
}
}

View file

@ -33,6 +33,7 @@ fun SearchList(
setSearchQuery: (String) -> Unit,
contentPadding: PaddingValues,
modifier: Modifier = Modifier,
onPostClick: (String) -> Unit,
) {
val triggerSearch = { query: String ->
setSearchQuery(query)
@ -53,6 +54,7 @@ fun SearchList(
listState = listState,
postActions = postActions,
contentPadding = contentPadding,
onPostClick = onPostClick,
)
}
}

View file

@ -167,7 +167,7 @@ fun LobstersPostsScreen(
navController.previousBackStackEntry != null && currentDestination.none(navDestinations)
) {
IconButton(
onClick = { if (!navController.navigateUp()) context.getActivity()?.finish() }
onClick = { if (!navController.popBackStack()) context.getActivity()?.finish() }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
@ -238,6 +238,7 @@ fun LobstersPostsScreen(
listState = hottestListState,
postActions = postActions,
contentPadding = contentPadding,
onPostClick = { postId -> navController.navigate(Comments(postId)) },
)
}
composable<Newest> {
@ -247,6 +248,7 @@ fun LobstersPostsScreen(
listState = newestListState,
postActions = postActions,
contentPadding = contentPadding,
onPostClick = { postId -> navController.navigate(Comments(postId)) },
)
}
composable<Saved> {

View file

@ -42,6 +42,8 @@ fun SearchScreen(
val postActions = remember { PostActions(context, urlLauncher, navController, viewModel) }
val listState = rememberLazyListState()
val searchResults = viewModel.searchResults.collectAsLazyPagingItems()
val onPostClick: (String) -> Unit = { postId -> navController.navigate("comments/$postId") }
Scaffold(modifier = modifier) { contentPadding ->
NavHost(navController = navController, startDestination = Search) {
composable<Search> {
@ -53,6 +55,7 @@ fun SearchScreen(
searchQuery = viewModel.searchQuery,
contentPadding = contentPadding,
setSearchQuery = { query -> viewModel.searchQuery = query },
onPostClick = onPostClick,
)
}
composable<Comments> { backStackEntry ->

View file

@ -0,0 +1,125 @@
/*
* Copyright © 2024 Harsh Shandilya.
* Use of this source code is governed by an MIT-style
* license that can be found in the LICENSE file or at
* https://opensource.org/licenses/MIT.
*/
package dev.msfjarvis.claw.android.ui.screens
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.material3.adaptive.navigation.NavigableListDetailPaneScaffold
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.navigation.compose.rememberNavController
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.android.ui.TwoPaneLayoutPostActions
import dev.msfjarvis.claw.android.ui.lists.NetworkPostsForTwoPaneLayout
import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.viewmodel.ClawViewModel
import dev.msfjarvis.claw.common.comments.CommentsPage
import dev.msfjarvis.claw.common.comments.HTMLConverter
import dev.msfjarvis.claw.common.urllauncher.UrlLauncher
@OptIn(ExperimentalMaterial3AdaptiveApi::class, ExperimentalMaterial3Api::class)
@Composable
fun TwoPaneLayout(
urlLauncher: UrlLauncher,
htmlConverter: HTMLConverter,
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
) {
val context = LocalContext.current
val hottestListState = rememberLazyListState()
val navController = rememberNavController()
// Track the selected postId as a mutable state
var selectedPostId by remember { mutableStateOf<String?>(null) }
// Initialize postActions with selectedPostId as a mutable state
val postActions = remember {
TwoPaneLayoutPostActions(context, urlLauncher, viewModel, { selectedPostId = it })
}
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
// Navigator state
val navigator = rememberListDetailPaneScaffoldNavigator<Any>()
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "The app icon for Claw",
modifier = Modifier.size(48.dp),
)
},
title = { Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold) },
)
},
content = { paddingValues ->
NavigableListDetailPaneScaffold(
modifier = modifier.padding(paddingValues),
navigator = navigator,
listPane = {
NetworkPostsForTwoPaneLayout(
lazyPagingItems = hottestPosts,
listState = hottestListState,
postActions = postActions,
contentPadding = PaddingValues(),
modifier = Modifier.fillMaxSize(),
) { postId ->
selectedPostId = postId // Update selectedPostId on click
}
},
detailPane = {
selectedPostId?.let { postId ->
CommentsPage(
postId = postId,
postActions = postActions,
htmlConverter = htmlConverter,
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
openUserProfile = { navController.navigate(User(it)) },
contentPadding = PaddingValues(),
modifier = Modifier.fillMaxSize(),
)
}
?: Box(Modifier.fillMaxSize()) {
// Placeholder when no post is selected
Text(
text = "Select a post to view comments",
modifier = Modifier.align(Alignment.Center),
)
}
},
)
},
)
}

View file

@ -31,6 +31,7 @@ class NetworkPostsTest {
listState = rememberLazyListState(),
postActions = TEST_POST_ACTIONS,
contentPadding = PaddingValues(),
onPostClick = {},
)
}
}