[1.224.*] Pre-release merge (#696)

This commit is contained in:
tramline-github[bot]
2024-10-27 14:30:08 +00:00
committed by GitHub
17 changed files with 380 additions and 26 deletions
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- A brand new tablet-specific UI — Thanks [@ThanaReka](https://github.com/@ThanaReka)
### Fixes
- Add a workaround for rare crashes while loading SQLite with older devices
+4
View File
@@ -17,6 +17,7 @@ plugins {
id("dev.msfjarvis.claw.kotlin-kapt")
id("dev.msfjarvis.claw.sentry")
id("dev.msfjarvis.claw.versioning-plugin")
id("kotlin-parcelize")
alias(libs.plugins.aboutlibraries)
alias(libs.plugins.android.junit5)
alias(libs.plugins.anvil)
@@ -89,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,
)
}
}
@@ -6,22 +6,24 @@
*/
package dev.msfjarvis.claw.android.ui.navigation
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
import kotlinx.serialization.Serializable
sealed interface Destination
sealed interface Destination : Parcelable
@Serializable data object Hottest : Destination
@Parcelize @Serializable data object Hottest : Destination
@Serializable data object Newest : Destination
@Parcelize @Serializable data object Newest : Destination
@Serializable data object Saved : Destination
@Parcelize @Serializable data object Saved : Destination
@Serializable data class Comments(val postId: String) : Destination
@Parcelize @Serializable data class Comments(val postId: String) : Destination
@Serializable data class User(val username: String) : Destination
@Parcelize @Serializable data class User(val username: String) : Destination
@Serializable data object Search : Destination
@Parcelize @Serializable data object Search : Destination
@Serializable data object Settings : Destination
@Parcelize @Serializable data object Settings : Destination
@Serializable data object AboutLibraries : Destination
@Parcelize @Serializable data object AboutLibraries : Destination
@@ -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 = {},
)
}
}
+1
View File
@@ -48,6 +48,7 @@ dependencies {
implementation(libs.androidx.compose.foundation)
implementation(libs.androidx.compose.material.icons.extended)
implementation(libs.androidx.compose.material3)
implementation(libs.androidx.compose.material3.adaptive)
implementation(libs.androidx.compose.runtime)
implementation(libs.androidx.compose.ui.text)
implementation(libs.androidx.core)
@@ -51,7 +51,7 @@ internal fun CommentsHeader(
modifier: Modifier = Modifier,
) {
val linkMetadata by
produceState(initialValue = LinkMetadata(post.url, null)) {
produceState(initialValue = LinkMetadata(post.url, null), key1 = post) {
runSuspendCatching { postActions.getLinkMetadata(post.url) }
.onSuccess { metadata -> value = metadata }
}
@@ -40,7 +40,7 @@ fun CommentsPage(
openUserProfile: (String) -> Unit,
) {
val postDetails by
produceState<NetworkState>(Loading) {
produceState<NetworkState>(Loading, key1 = postId) {
runSuspendCatching { postActions.getComments(postId) }
.fold(
success = { details -> value = Success(details) },
@@ -48,7 +48,9 @@ fun CommentsPage(
)
}
val commentState by
produceState<PostComments?>(initialValue = null) { value = getSeenComments(postId) }
produceState<PostComments?>(initialValue = null, key1 = postId) {
value = getSeenComments(postId)
}
when (postDetails) {
is Success<*> -> {
@@ -60,7 +62,6 @@ fun CommentsPage(
markSeenComments = markSeenComments,
openUserProfile = openUserProfile,
contentPadding = contentPadding,
commentsHandler = CommentsHandler(),
modifier = modifier.fillMaxSize(),
)
}
@@ -64,11 +64,12 @@ internal fun CommentsPageInternal(
markSeenComments: (String, List<Comment>) -> Unit,
openUserProfile: (String) -> Unit,
contentPadding: PaddingValues,
commentsHandler: CommentsHandler,
modifier: Modifier = Modifier,
) {
LaunchedEffect(Unit) { commentsHandler.createListNode(details.comments, commentState) }
val commentsHandler = CommentsHandler()
LaunchedEffect(key1 = details, key2 = commentState) {
commentsHandler.createListNode(details.comments, commentState)
}
val onToggleExpandedState = { shortId: String, isExpanded: Boolean ->
commentsHandler.updateListNode(shortId, isExpanded)
@@ -77,7 +78,7 @@ internal fun CommentsPageInternal(
val context = LocalContext.current
val commentNodes by commentsHandler.listItems.collectAsStateWithLifecycle()
LaunchedEffect(key1 = commentNodes) {
LaunchedEffect(key1 = details, key2 = commentState) {
if (details.comments.isNotEmpty() && !commentState?.commentIds.isNullOrEmpty()) {
val unreadCount = details.comments.size - (commentState?.commentIds?.size ?: 0)
if (unreadCount > 0) {
+4 -1
View File
@@ -7,7 +7,7 @@ coil = "2.7.0"
coroutines = "1.9.0"
dagger = "2.52"
glance = "1.0.0"
haze = "0.9.0-rc01"
haze = "0.9.0-rc02"
junit = "5.11.3"
konvert = "3.2.2"
kotlin = "2.0.21"
@@ -37,6 +37,9 @@ androidx-compose-glance-m3 = { module = "androidx.glance:glance-material3", vers
androidx-compose-material-icons-extended = { module = "androidx.compose.material:material-icons-extended" }
androidx-compose-material3 = { module = "androidx.compose.material3:material3" }
androidx-compose-material3-window-size = { module = "androidx.compose.material3:material3-window-size-class" }
androidx-compose-material3-adaptive = { module = "androidx.compose.material3.adaptive:adaptive"}
androidx-compose-material3-adaptive-layout = { module = "androidx.compose.material3.adaptive:adaptive-layout"}
androidx-compose-material3-adaptive-navigation = { module = "androidx.compose.material3.adaptive:adaptive-navigation"}
androidx-compose-runtime = { module = "androidx.compose.runtime:runtime" }
androidx-compose-ui = { module = "androidx.compose.ui:ui" }
androidx-compose-ui-text = { module = "androidx.compose.ui:ui-text" }