mirror of
https://github.com/msfjarvis/compose-lobsters
synced 2025-08-14 23:27:04 +05:30
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:
parent
f215210ffa
commit
b84f266db7
14 changed files with 360 additions and 16 deletions
|
@ -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)
|
||||
|
|
|
@ -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() {
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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> {
|
||||
|
|
|
@ -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 ->
|
||||
|
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
|
@ -31,6 +31,7 @@ class NetworkPostsTest {
|
|||
listState = rememberLazyListState(),
|
||||
postActions = TEST_POST_ACTIONS,
|
||||
contentPadding = PaddingValues(),
|
||||
onPostClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue