feat: rewrite navigation on top of Nav3

This commit is contained in:
Harsh Shandilya 2025-05-25 13:25:57 +05:30
parent 9b322c212f
commit 17289a26f1
9 changed files with 115 additions and 582 deletions

View file

@ -107,6 +107,8 @@ dependencies {
implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.lifecycle.compose) implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.navigation.compose) implementation(libs.androidx.navigation.compose)
implementation(libs.androidx.navigation3.runtime)
implementation(libs.androidx.navigation3.ui)
implementation(libs.androidx.paging.compose) implementation(libs.androidx.paging.compose)
implementation(libs.androidx.profileinstaller) implementation(libs.androidx.profileinstaller)
implementation(libs.androidx.work.runtime) implementation(libs.androidx.work.runtime)

View file

@ -33,19 +33,6 @@
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".SearchActivity"
android:configChanges="colorMode|density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:exported="true"
android:label="Claw Search"
android:launchMode="singleTask"
android:taskAffinity=""
android:theme="@style/Theme.Claw">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
</intent-filter>
</activity>
<provider <provider
android:name="androidx.startup.InitializationProvider" android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup" android:authorities="${applicationId}.androidx-startup"

View file

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

View file

@ -1,19 +0,0 @@
/*
* Copyright © 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
import androidx.compose.runtime.Composable
import com.deliveryhero.whetstone.activity.ContributesActivityInjector
import dev.msfjarvis.claw.android.ui.screens.SearchScreen
@ContributesActivityInjector
class SearchActivity : BaseActivity() {
@Composable
override fun Content() {
SearchScreen(urlLauncher = urlLauncher, setWebUri = { webUri = it }, viewModel = viewModel)
}
}

View file

@ -20,21 +20,19 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.snapshots.SnapshotStateList
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.navigation.NavController
import androidx.navigation.compose.currentBackStackEntryAsState
import dev.chrisbanes.haze.HazeDefaults import dev.chrisbanes.haze.HazeDefaults
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.HazeStyle import dev.chrisbanes.haze.HazeStyle
import dev.chrisbanes.haze.hazeEffect import dev.chrisbanes.haze.hazeEffect
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.Destination import dev.msfjarvis.claw.android.ui.navigation.Destination
import dev.msfjarvis.claw.android.ui.navigation.matches
import dev.msfjarvis.claw.common.ui.FloatingNavigationBar import dev.msfjarvis.claw.common.ui.FloatingNavigationBar
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@ -42,7 +40,7 @@ const val AnimationDuration = 100
@Composable @Composable
fun ClawNavigationBar( fun ClawNavigationBar(
navController: NavController, backStack: SnapshotStateList<Destination>,
items: ImmutableList<NavigationItem>, items: ImmutableList<NavigationItem>,
isVisible: Boolean, isVisible: Boolean,
hazeState: HazeState, hazeState: HazeState,
@ -85,10 +83,9 @@ fun ClawNavigationBar(
containerColor = containerColor =
if (HazeDefaults.blurEnabled()) Color.Transparent else MaterialTheme.colorScheme.surface, if (HazeDefaults.blurEnabled()) Color.Transparent else MaterialTheme.colorScheme.surface,
) { ) {
val navBackStackEntry = navController.currentBackStackEntryAsState().value val currentDestination = backStack.firstOrNull()
val currentDestination = navBackStackEntry?.destination
items.forEach { navItem -> items.forEach { navItem ->
val isSelected = currentDestination.matches(navItem.destination) val isSelected = currentDestination == navItem.destination
NavigationBarItem( NavigationBarItem(
icon = { icon = {
Crossfade(isSelected, label = "nav-label") { Crossfade(isSelected, label = "nav-label") {
@ -104,11 +101,7 @@ fun ClawNavigationBar(
if (isSelected) { if (isSelected) {
navItem.listStateResetCallback() navItem.listStateResetCallback()
} else { } else {
navController.navigate(navItem.destination) { backStack.add(navItem.destination)
popUpTo(navController.graph.startDestinationId) { saveState = true }
launchSingleTop = true
restoreState = true
}
} }
}, },
modifier = Modifier.testTag(navItem.label.uppercase()), modifier = Modifier.testTag(navItem.label.uppercase()),

View file

@ -6,13 +6,8 @@
*/ */
package dev.msfjarvis.claw.android.ui.screens package dev.msfjarvis.claw.android.ui.screens
import android.content.Intent
import androidx.activity.compose.LocalActivity import androidx.activity.compose.LocalActivity
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
@ -32,6 +27,7 @@ import androidx.compose.material3.windowsizeclass.WindowSizeClass
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -44,22 +40,17 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost import androidx.navigation3.runtime.entry
import androidx.navigation.compose.composable import androidx.navigation3.runtime.entryProvider
import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation3.ui.NavDisplay
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import androidx.paging.compose.collectAsLazyPagingItems import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel import com.deliveryhero.whetstone.compose.injectedViewModel
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import dev.chrisbanes.haze.HazeState import dev.chrisbanes.haze.HazeState
import dev.chrisbanes.haze.hazeSource
import dev.msfjarvis.claw.android.MainActivity import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.android.R import dev.msfjarvis.claw.android.R
import dev.msfjarvis.claw.android.SearchActivity
import dev.msfjarvis.claw.android.ui.PostActions import dev.msfjarvis.claw.android.ui.PostActions
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationBar
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail
import dev.msfjarvis.claw.android.ui.decorations.NavigationItem import dev.msfjarvis.claw.android.ui.decorations.NavigationItem
import dev.msfjarvis.claw.android.ui.lists.DatabasePosts import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
@ -67,9 +58,11 @@ import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType
import dev.msfjarvis.claw.android.ui.navigation.Comments import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Destination
import dev.msfjarvis.claw.android.ui.navigation.Hottest import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.Search
import dev.msfjarvis.claw.android.ui.navigation.Settings import dev.msfjarvis.claw.android.ui.navigation.Settings
import dev.msfjarvis.claw.android.ui.navigation.User import dev.msfjarvis.claw.android.ui.navigation.User
import dev.msfjarvis.claw.android.ui.navigation.any import dev.msfjarvis.claw.android.ui.navigation.any
@ -85,26 +78,23 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LobstersPostsScreen( fun Nav3Screen(
urlLauncher: UrlLauncher, urlLauncher: UrlLauncher,
windowSizeClass: WindowSizeClass, windowSizeClass: WindowSizeClass,
setWebUri: (String?) -> Unit, setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(), viewModel: ClawViewModel = injectedViewModel(),
) { ) {
val backStack = remember { mutableStateListOf<Destination>(Hottest) }
// region Pain
val context = LocalContext.current val context = LocalContext.current
val activity = LocalActivity.current val activity = LocalActivity.current
val hottestListState = rememberLazyListState() val hottestListState = rememberLazyListState()
val newestListState = rememberLazyListState() val newestListState = rememberLazyListState()
val savedListState = rememberLazyListState() val savedListState = rememberLazyListState()
val navController = rememberNavController()
val navBackStackEntry = navController.currentBackStackEntryAsState().value
val currentDestination = navBackStackEntry?.destination
val coroutineScope = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val postActions = remember {
PostActions(context, urlLauncher, viewModel) { navController.navigate(Comments(it)) }
}
val hazeState = remember { HazeState() } val hazeState = remember { HazeState() }
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems() val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
@ -116,7 +106,7 @@ fun LobstersPostsScreen(
val postIdOverride = activity?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY) val postIdOverride = activity?.intent?.extras?.getString(MainActivity.NAVIGATION_KEY)
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
if (postIdOverride != null) { if (postIdOverride != null) {
navController.navigate(Comments(postIdOverride)) backStack.add(Comments(postIdOverride))
} }
} }
@ -137,16 +127,19 @@ fun LobstersPostsScreen(
}, },
) )
val navDestinations = navItems.map(NavigationItem::destination).toPersistentList() val navDestinations = navItems.map(NavigationItem::destination).toPersistentList()
// endregion
val postActions = remember {
PostActions(context, urlLauncher, viewModel) { backStack.add(Comments(it)) }
}
Scaffold( Scaffold(
topBar = { topBar = {
TopAppBar( TopAppBar(
modifier = Modifier.shadow(8.dp), modifier = Modifier.shadow(8.dp),
navigationIcon = { navigationIcon = {
if ( if (backStack.none { it in navDestinations }) {
navController.previousBackStackEntry != null && currentDestination.none(navDestinations) IconButton(onClick = { if (backStack.removeLastOrNull() == null) activity?.finish() }) {
) {
IconButton(onClick = { if (!navController.popBackStack()) activity?.finish() }) {
Icon( Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack, imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to previous screen", contentDescription = "Go back to previous screen",
@ -161,18 +154,16 @@ fun LobstersPostsScreen(
} }
}, },
title = { title = {
if (currentDestination.any(navDestinations)) { if (backStack.any { it in navDestinations }) {
Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold) Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
} }
}, },
actions = { actions = {
if (currentDestination.any(navDestinations)) { if (backStack.any { it in navDestinations }) {
IconButton( IconButton(onClick = { backStack.add(Search) }) {
onClick = { context.startActivity(Intent(context, SearchActivity::class.java)) }
) {
Icon(imageVector = Icons.Filled.Search, contentDescription = "Search posts") Icon(imageVector = Icons.Filled.Search, contentDescription = "Search posts")
} }
IconButton(onClick = { navController.navigate(Settings) }) { IconButton(onClick = { backStack.add(Settings) }) {
Icon(imageVector = Icons.Filled.Tune, contentDescription = "Settings") Icon(imageVector = Icons.Filled.Tune, contentDescription = "Settings")
} }
} }
@ -182,99 +173,87 @@ fun LobstersPostsScreen(
bottomBar = { bottomBar = {
AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) { AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) {
ClawNavigationBar( ClawNavigationBar(
navController = navController, backStack,
items = navItems, items = navItems,
isVisible = currentDestination.any(navDestinations), isVisible = backStack.any { it in navDestinations },
hazeState = hazeState, hazeState = hazeState,
) )
} }
}, },
snackbarHost = { SnackbarHost(snackbarHostState) }, snackbarHost = { SnackbarHost(snackbarHostState) },
modifier = modifier.semantics { testTagsAsResourceId = true }, modifier = Modifier.semantics { testTagsAsResourceId = true },
) { contentPadding -> ) { contentPadding ->
Row { NavDisplay(
AnimatedVisibility(visible = navigationType == ClawNavigationType.NAVIGATION_RAIL) { backStack = backStack,
ClawNavigationRail( modifier = modifier,
navController = navController, onBack = { backStack.removeLastOrNull() },
items = navItems, entryProvider =
isVisible = currentDestination.any(navDestinations), entryProvider {
) entry<Hottest> {
} setWebUri("https://lobste.rs/")
NetworkPosts(
NavHost( lazyPagingItems = hottestPosts,
navController = navController, listState = hottestListState,
startDestination = Hottest, postActions = postActions,
// Make animations 2x faster than default specs contentPadding = contentPadding,
enterTransition = { fadeIn(animationSpec = tween(350)) }, )
exitTransition = { fadeOut(animationSpec = tween(350)) }, }
modifier = Modifier.hazeSource(hazeState), entry<Newest> {
) { setWebUri("https://lobste.rs/")
composable<Hottest> { NetworkPosts(
setWebUri("https://lobste.rs/") lazyPagingItems = newestPosts,
NetworkPosts( listState = newestListState,
lazyPagingItems = hottestPosts, postActions = postActions,
listState = hottestListState, contentPadding = contentPadding,
postActions = postActions, )
contentPadding = contentPadding, }
) entry<Saved> {
} setWebUri(null)
composable<Newest> { DatabasePosts(
setWebUri("https://lobste.rs/") items = savedPosts,
NetworkPosts( listState = savedListState,
lazyPagingItems = newestPosts, postActions = postActions,
listState = newestListState, contentPadding = contentPadding,
postActions = postActions, )
contentPadding = contentPadding, }
) entry<Settings> {
} SettingsScreen(
composable<Saved> { openInputStream = context.contentResolver::openInputStream,
setWebUri(null) openOutputStream = context.contentResolver::openOutputStream,
DatabasePosts( openLibrariesScreen = { backStack.add(AboutLibraries) },
items = savedPosts, importPosts = viewModel::importPosts,
listState = savedListState, exportPostsAsJson = viewModel::exportPostsAsJson,
postActions = postActions, exportPostsAsHtml = viewModel::exportPostsAsHtml,
contentPadding = contentPadding, snackbarHostState = snackbarHostState,
) contentPadding = contentPadding,
} modifier = Modifier.fillMaxSize(),
composable<Comments> { backStackEntry -> )
val postId = backStackEntry.toRoute<Comments>().postId }
setWebUri("https://lobste.rs/s/$postId") entry<Comments> { dest ->
CommentsPage( CommentsPage(
postId = postId, postId = dest.postId,
postActions = postActions, postActions = postActions,
getSeenComments = viewModel::getSeenComments, getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments, markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding, contentPadding = contentPadding,
openUserProfile = { navController.navigate(User(it)) }, openUserProfile = { backStack.add(User(it)) },
) )
} }
composable<User> { backStackEntry -> entry<User> { dest ->
val username = backStackEntry.toRoute<User>().username UserProfile(
setWebUri("https://lobste.rs/u/$username") username = dest.username,
UserProfile( getProfile = viewModel::getUserProfile,
username = username, contentPadding = contentPadding,
getProfile = viewModel::getUserProfile, openUserProfile = { backStack.add(User(it)) },
contentPadding = contentPadding, )
openUserProfile = { navController.navigate(User(it)) }, }
) entry<Search> {
} SearchScreen(urlLauncher = urlLauncher, setWebUri = setWebUri, viewModel = viewModel)
composable<Settings> { }
SettingsScreen( entry<AboutLibraries> {
openInputStream = context.contentResolver::openInputStream, LibrariesContainer(contentPadding = contentPadding, modifier = Modifier.fillMaxSize())
openOutputStream = context.contentResolver::openOutputStream, }
openLibrariesScreen = { navController.navigate(AboutLibraries) }, },
importPosts = viewModel::importPosts, )
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
snackbarHostState = snackbarHostState,
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize(),
)
}
composable<AboutLibraries> {
LibrariesContainer(contentPadding = contentPadding, modifier = Modifier.fillMaxSize())
}
}
}
} }
} }

View file

@ -1,234 +0,0 @@
/*
* Copyright © 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.
*/
@file:OptIn(ExperimentalMaterial3AdaptiveApi::class)
package dev.msfjarvis.claw.android.ui.screens
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
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.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
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.layout.AnimatedPane
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffold
import androidx.compose.material3.adaptive.layout.ListDetailPaneScaffoldRole
import androidx.compose.material3.adaptive.layout.PaneAdaptedValue
import androidx.compose.material3.adaptive.navigation.BackNavigationBehavior
import androidx.compose.material3.adaptive.navigation.ThreePaneScaffoldNavigator
import androidx.compose.material3.adaptive.navigation.rememberListDetailPaneScaffoldNavigator
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
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.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
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.PostActions
import dev.msfjarvis.claw.android.ui.decorations.ClawNavigationRail
import dev.msfjarvis.claw.android.ui.decorations.NavigationItem
import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
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.urllauncher.UrlLauncher
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.coroutines.launch
private fun ThreePaneScaffoldNavigator<*>.isListExpanded() =
scaffoldValue[ListDetailPaneScaffoldRole.List] == PaneAdaptedValue.Expanded
private fun ThreePaneScaffoldNavigator<*>.isDetailExpanded() =
scaffoldValue[ListDetailPaneScaffoldRole.Detail] == PaneAdaptedValue.Expanded
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun TabletScreen(
urlLauncher: UrlLauncher,
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
) {
val context = LocalContext.current
val hottestListState = rememberLazyListState()
val newestListState = rememberLazyListState()
val savedListState = rememberLazyListState()
val navController = rememberNavController()
val coroutineScope = rememberCoroutineScope()
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
val savedPosts by viewModel.savedPostsByMonth.collectAsStateWithLifecycle(persistentMapOf())
val navigator = rememberListDetailPaneScaffoldNavigator<Comments>()
val backBehavior =
if (navigator.isListExpanded() && navigator.isDetailExpanded()) {
BackNavigationBehavior.PopUntilContentChange
} else {
BackNavigationBehavior.PopUntilScaffoldValueChange
}
val postActions = remember {
PostActions(context, urlLauncher, viewModel) {
coroutineScope.launch {
navigator.navigateTo(pane = ListDetailPaneScaffoldRole.Detail, contentKey = Comments(it))
}
}
}
val navItems =
persistentListOf(
NavigationItem(AppDestinations.HOTTEST) {
coroutineScope.launch {
if (hottestPosts.itemCount > 0) hottestListState.animateScrollToItem(index = 0)
}
},
NavigationItem(AppDestinations.NEWEST) {
coroutineScope.launch {
if (newestPosts.itemCount > 0) newestListState.animateScrollToItem(index = 0)
}
},
NavigationItem(AppDestinations.SAVED) {
coroutineScope.launch {
if (savedPosts.isNotEmpty()) savedListState.animateScrollToItem(index = 0)
}
},
)
BackHandler(navigator.canNavigateBack(backBehavior)) {
coroutineScope.launch { navigator.navigateBack(backBehavior) }
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = {
if (navigator.canNavigateBack(backBehavior)) {
IconButton(
onClick = { coroutineScope.launch { navigator.navigateBack(backBehavior) } }
) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = "Go back to previous screen",
)
}
} else {
Icon(
painter = painterResource(id = R.drawable.ic_launcher_foreground),
contentDescription = "The app icon for Claw",
modifier = Modifier.size(48.dp),
)
}
},
title = {
if (!navigator.canNavigateBack(backBehavior)) {
Text(text = stringResource(R.string.app_name), fontWeight = FontWeight.Bold)
}
},
)
},
content = { paddingValues ->
Row {
ClawNavigationRail(navController = navController, items = navItems, isVisible = true)
ListDetailPaneScaffold(
modifier = modifier.padding(paddingValues),
directive = navigator.scaffoldDirective,
value = navigator.scaffoldValue,
listPane = {
AnimatedPane {
NavHost(
navController = navController,
startDestination = Hottest,
enterTransition = { fadeIn(tween(350)) },
exitTransition = { fadeOut(tween(350)) },
) {
composable<Hottest> {
NetworkPosts(
lazyPagingItems = hottestPosts,
listState = hottestListState,
postActions = postActions,
contentPadding = PaddingValues(),
)
}
composable<Newest> {
NetworkPosts(
lazyPagingItems = newestPosts,
listState = newestListState,
postActions = postActions,
contentPadding = PaddingValues(),
)
}
composable<Saved> {
DatabasePosts(
items = savedPosts,
listState = savedListState,
postActions = postActions,
contentPadding = PaddingValues(),
)
}
}
}
},
detailPane = {
AnimatedPane {
when (val contentKey = navigator.currentDestination?.contentKey) {
null -> {
Box(Modifier.fillMaxSize()) {
Text(
text = "Select a post to view comments",
modifier = Modifier.align(Alignment.Center),
)
}
}
else -> {
CommentsPage(
postId = contentKey.postId,
postActions = postActions,
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
contentPadding = PaddingValues(),
modifier = Modifier.fillMaxSize(),
openUserProfile = { navController.navigate(User(it)) },
)
}
}
}
},
)
}
},
)
}

View file

@ -1,167 +0,0 @@
/*
* Copyright © 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.activity.compose.BackHandler
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.Icon
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.adaptive.navigationsuite.NavigationSuiteScaffold
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.paging.compose.collectAsLazyPagingItems
import com.deliveryhero.whetstone.compose.injectedViewModel
import com.mikepenz.aboutlibraries.ui.compose.m3.LibrariesContainer
import dev.msfjarvis.claw.android.ui.PostActions
import dev.msfjarvis.claw.android.ui.lists.DatabasePosts
import dev.msfjarvis.claw.android.ui.lists.NetworkPosts
import dev.msfjarvis.claw.android.ui.navigation.AboutLibraries
import dev.msfjarvis.claw.android.ui.navigation.AppDestinations
import dev.msfjarvis.claw.android.ui.navigation.Comments
import dev.msfjarvis.claw.android.ui.navigation.Hottest
import dev.msfjarvis.claw.android.ui.navigation.Newest
import dev.msfjarvis.claw.android.ui.navigation.Saved
import dev.msfjarvis.claw.android.ui.navigation.Settings
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.urllauncher.UrlLauncher
import dev.msfjarvis.claw.common.user.UserProfile
import kotlinx.collections.immutable.persistentMapOf
@Composable
fun TabletScreen2(
urlLauncher: UrlLauncher,
setWebUri: (String?) -> Unit,
modifier: Modifier = Modifier,
viewModel: ClawViewModel = injectedViewModel(),
) {
// TODO: Needs a custom Saver implementation, should probably be an ArrayDeque
val navigationBackStack = rememberSaveable {
mutableStateListOf(AppDestinations.HOTTEST.destination)
}
val context = LocalContext.current
val hottestListState = rememberLazyListState()
val newestListState = rememberLazyListState()
val savedListState = rememberLazyListState()
val snackbarHostState = remember { SnackbarHostState() }
val postActions = remember {
PostActions(context, urlLauncher, viewModel) { navigationBackStack.add(Comments(it)) }
}
val hottestPosts = viewModel.hottestPosts.collectAsLazyPagingItems()
val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems()
val savedPosts by viewModel.savedPostsByMonth.collectAsStateWithLifecycle(persistentMapOf())
BackHandler(navigationBackStack.size > 1) {
navigationBackStack.removeAt(navigationBackStack.size - 1)
}
val contentPadding = PaddingValues()
NavigationSuiteScaffold(
navigationSuiteItems = {
AppDestinations.entries.forEach {
item(
icon = { Icon(imageVector = it.icon, contentDescription = it.label) },
selected = it.destination == navigationBackStack.first(),
onClick = { navigationBackStack.add(it.destination) },
)
}
},
modifier = modifier,
) {
when (navigationBackStack.first()) {
AboutLibraries -> {
LibrariesContainer(contentPadding = contentPadding, modifier = Modifier.fillMaxSize())
}
Hottest -> {
setWebUri("https://lobste.rs/")
NetworkPosts(
lazyPagingItems = hottestPosts,
listState = hottestListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
Newest -> {
setWebUri("https://lobste.rs/")
NetworkPosts(
lazyPagingItems = newestPosts,
listState = newestListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
Saved -> {
setWebUri(null)
DatabasePosts(
items = savedPosts,
listState = savedListState,
postActions = postActions,
contentPadding = contentPadding,
)
}
Settings -> {
setWebUri(null)
SettingsScreen(
openInputStream = context.contentResolver::openInputStream,
openOutputStream = context.contentResolver::openOutputStream,
openLibrariesScreen = { navigationBackStack.add(AboutLibraries) },
importPosts = viewModel::importPosts,
exportPostsAsJson = viewModel::exportPostsAsJson,
exportPostsAsHtml = viewModel::exportPostsAsHtml,
snackbarHostState = snackbarHostState,
contentPadding = contentPadding,
modifier = Modifier.fillMaxSize(),
)
}
is Comments -> {
val postId = (navigationBackStack.first() as Comments).postId
setWebUri("https://lobste.rs/s/$postId")
CommentsPage(
postId = postId,
postActions = postActions,
getSeenComments = viewModel::getSeenComments,
markSeenComments = viewModel::markSeenComments,
contentPadding = contentPadding,
openUserProfile = { navigationBackStack.add(User(it)) },
)
}
is User -> {
val username = (navigationBackStack.first() as User).username
setWebUri("https://lobste.rs/u/$username")
UserProfile(
username = username,
getProfile = viewModel::getUserProfile,
contentPadding = contentPadding,
openUserProfile = { navigationBackStack.add(User(it)) },
)
}
else -> {
Box(Modifier.fillMaxSize()) {
Text(
text = "Unexpected destination: ${navigationBackStack.first()}, please report this bug",
modifier = Modifier.align(Alignment.Center),
)
}
}
}
}
}

View file

@ -17,6 +17,7 @@ kotlinResult = "2.0.1"
leakcanary = "3.0-alpha-8" leakcanary = "3.0-alpha-8"
lifecycle = "2.9.0" lifecycle = "2.9.0"
navigation = "2.9.0" navigation = "2.9.0"
navigation3 = "1.0.0-alpha02"
retrofit = "3.0.0" retrofit = "3.0.0"
richtext = "1.0.0-alpha02" richtext = "1.0.0-alpha02"
sentry-sdk = "8.12.0" sentry-sdk = "8.12.0"
@ -55,6 +56,8 @@ androidx-core-splashscreen = "androidx.core:core-splashscreen:1.2.0-beta02"
androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" } androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lint-gradle = "androidx.lint:lint-gradle:1.0.0-alpha05" androidx-lint-gradle = "androidx.lint:lint-gradle:1.0.0-alpha05"
androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" } androidx-navigation-compose = { module = "androidx.navigation:navigation-compose", version.ref = "navigation" }
androidx-navigation3-runtime = { module = "androidx.navigation3:navigation3-runtime", version.ref = "navigation3" }
androidx-navigation3-ui = { module = "androidx.navigation3:navigation3-ui", version.ref = "navigation3" }
androidx-paging-compose = "androidx.paging:paging-compose:3.3.6" androidx-paging-compose = "androidx.paging:paging-compose:3.3.6"
androidx-profileinstaller = "androidx.profileinstaller:profileinstaller:1.4.1" androidx-profileinstaller = "androidx.profileinstaller:profileinstaller:1.4.1"
androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" } androidx-test-core = { module = "androidx.test:core", version.ref = "androidx-test" }