diff --git a/android/build.gradle.kts b/android/build.gradle.kts index 4a79ba14..1a8adcb6 100644 --- a/android/build.gradle.kts +++ b/android/build.gradle.kts @@ -53,6 +53,7 @@ dependencies { implementation(libs.androidx.compose.material) implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.compose.material3) + implementation(libs.androidx.compose.material3.window.size) implementation(libs.androidx.core.splashscreen) implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.lifecycle.compose) diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/MainActivity.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/MainActivity.kt index 40824072..8d555323 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/MainActivity.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/MainActivity.kt @@ -11,6 +11,8 @@ import android.net.Uri import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.material3.windowsizeclass.ExperimentalMaterial3WindowSizeClassApi +import androidx.compose.material3.windowsizeclass.calculateWindowSizeClass import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen import androidx.core.view.WindowCompat import com.deliveryhero.whetstone.Whetstone @@ -27,15 +29,19 @@ class MainActivity : ComponentActivity() { @Inject lateinit var htmlConverter: HTMLConverter private var webUri: String? = null + @OptIn(ExperimentalMaterial3WindowSizeClassApi::class) override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) installSplashScreen() Whetstone.inject(this) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { + val windowSizeClass = calculateWindowSizeClass(this) + LobstersApp( urlLauncher = urlLauncher, htmlConverter = htmlConverter, + windowSizeClass = windowSizeClass, setWebUri = { url -> webUri = url }, ) } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/LobstersApp.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/LobstersApp.kt index ac907878..d7e12935 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/LobstersApp.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/LobstersApp.kt @@ -6,6 +6,8 @@ */ package dev.msfjarvis.claw.android.ui +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material.icons.Icons @@ -20,6 +22,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Scaffold import androidx.compose.material3.Text +import androidx.compose.material3.windowsizeclass.WindowSizeClass import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -44,10 +47,12 @@ import androidx.paging.compose.collectAsLazyPagingItems import com.deliveryhero.whetstone.compose.injectedViewModel import dev.msfjarvis.claw.android.R 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.TransparentSystemBars import dev.msfjarvis.claw.android.ui.lists.DatabasePosts import dev.msfjarvis.claw.android.ui.lists.NetworkPosts +import dev.msfjarvis.claw.android.ui.navigation.ClawNavigationType import dev.msfjarvis.claw.android.ui.navigation.Destinations import dev.msfjarvis.claw.android.viewmodel.ClawViewModel import dev.msfjarvis.claw.api.LobstersApi @@ -66,6 +71,7 @@ import kotlinx.coroutines.launch fun LobstersApp( urlLauncher: UrlLauncher, htmlConverter: HTMLConverter, + windowSizeClass: WindowSizeClass, setWebUri: (String?) -> Unit, modifier: Modifier = Modifier, viewModel: ClawViewModel = injectedViewModel(), @@ -84,6 +90,8 @@ fun LobstersApp( val newestPosts = viewModel.newestPosts.collectAsLazyPagingItems() val savedPosts by viewModel.savedPosts.collectAsState(persistentMapOf()) + val navigationType = ClawNavigationType.fromSize(windowSizeClass.widthSizeClass) + LobstersTheme( dynamicColor = true, providedValues = arrayOf(LocalUriHandler provides urlLauncher), @@ -141,83 +149,94 @@ fun LobstersApp( ) }, bottomBar = { - ClawNavigationBar( - navController = navController, - items = navItems, - isVisible = navItems.any { it.route == currentDestination }, - ) + AnimatedVisibility(visible = navigationType == ClawNavigationType.BOTTOM_NAVIGATION) { + ClawNavigationBar( + navController = navController, + items = navItems, + isVisible = navItems.any { it.route == currentDestination }, + ) + } }, modifier = modifier.semantics { testTagsAsResourceId = true }, ) { paddingValues -> - NavHost( - navController = navController, - startDestination = Destinations.startDestination.route, - modifier = Modifier.padding(paddingValues), - ) { - val uri = LobstersApi.BASE_URL - composable( - route = Destinations.Hottest.route, - deepLinks = - listOf(navDeepLink { uriPattern = uri }, navDeepLink { uriPattern = "$uri/" }), - ) { - setWebUri("https://lobste.rs/") - NetworkPosts( - lazyPagingItems = hottestPosts, - listState = hottestListState, - isPostSaved = viewModel::isPostSaved, - postActions = postActions, + Row(modifier = Modifier.padding(paddingValues)) { + AnimatedVisibility(visible = navigationType != ClawNavigationType.BOTTOM_NAVIGATION) { + ClawNavigationRail( + navController = navController, + items = navItems, + isVisible = navItems.any { it.route == currentDestination }, ) } - composable( - route = Destinations.Newest.route, + + NavHost( + navController = navController, + startDestination = Destinations.startDestination.route, ) { - setWebUri("https://lobste.rs/") - NetworkPosts( - lazyPagingItems = newestPosts, - listState = newestListState, - isPostSaved = viewModel::isPostSaved, - postActions = postActions, - ) - } - composable(Destinations.Saved.route) { - setWebUri(null) - DatabasePosts( - items = savedPosts, - listState = savedListState, - postActions = postActions, - ) - } - composable( - route = Destinations.Comments.route, - arguments = listOf(navArgument("postId") { type = NavType.StringType }), - deepLinks = - listOf( - navDeepLink { uriPattern = "$uri/s/${Destinations.Comments.placeholder}/.*" }, - navDeepLink { uriPattern = "$uri/s/${Destinations.Comments.placeholder}" }, - ), - ) { backStackEntry -> - val postId = requireNotNull(backStackEntry.arguments?.getString("postId")) - setWebUri("https://lobste.rs/s/$postId") - CommentsPage( - postId = postId, - postActions = postActions, - htmlConverter = htmlConverter, - getSeenComments = viewModel::getSeenComments, - markSeenComments = viewModel::markSeenComments, - ) - } - composable( - route = Destinations.User.route, - arguments = listOf(navArgument("username") { type = NavType.StringType }), - deepLinks = - listOf(navDeepLink { uriPattern = "$uri/u/${Destinations.User.placeholder}" }), - ) { backStackEntry -> - val username = requireNotNull(backStackEntry.arguments?.getString("username")) - setWebUri("https://lobste.rs/u/$username") - UserProfile( - username = username, - getProfile = viewModel::getUserProfile, - ) + val uri = LobstersApi.BASE_URL + composable( + route = Destinations.Hottest.route, + deepLinks = + listOf(navDeepLink { uriPattern = uri }, navDeepLink { uriPattern = "$uri/" }), + ) { + setWebUri("https://lobste.rs/") + NetworkPosts( + lazyPagingItems = hottestPosts, + listState = hottestListState, + isPostSaved = viewModel::isPostSaved, + postActions = postActions, + ) + } + composable( + route = Destinations.Newest.route, + ) { + setWebUri("https://lobste.rs/") + NetworkPosts( + lazyPagingItems = newestPosts, + listState = newestListState, + isPostSaved = viewModel::isPostSaved, + postActions = postActions, + ) + } + composable(Destinations.Saved.route) { + setWebUri(null) + DatabasePosts( + items = savedPosts, + listState = savedListState, + postActions = postActions, + ) + } + composable( + route = Destinations.Comments.route, + arguments = listOf(navArgument("postId") { type = NavType.StringType }), + deepLinks = + listOf( + navDeepLink { uriPattern = "$uri/s/${Destinations.Comments.placeholder}/.*" }, + navDeepLink { uriPattern = "$uri/s/${Destinations.Comments.placeholder}" }, + ), + ) { backStackEntry -> + val postId = requireNotNull(backStackEntry.arguments?.getString("postId")) + setWebUri("https://lobste.rs/s/$postId") + CommentsPage( + postId = postId, + postActions = postActions, + htmlConverter = htmlConverter, + getSeenComments = viewModel::getSeenComments, + markSeenComments = viewModel::markSeenComments, + ) + } + composable( + route = Destinations.User.route, + arguments = listOf(navArgument("username") { type = NavType.StringType }), + deepLinks = + listOf(navDeepLink { uriPattern = "$uri/u/${Destinations.User.placeholder}" }), + ) { backStackEntry -> + val username = requireNotNull(backStackEntry.arguments?.getString("username")) + setWebUri("https://lobste.rs/u/$username") + UserProfile( + username = username, + getProfile = viewModel::getUserProfile, + ) + } } } } diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt index 1d97953e..8434082e 100644 --- a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationBar.kt @@ -25,7 +25,7 @@ import androidx.navigation.NavController import dev.msfjarvis.claw.android.ui.navigation.Destinations import kotlinx.collections.immutable.ImmutableList -private const val AnimationDuration = 100 +const val AnimationDuration = 100 @Composable fun ClawNavigationBar( diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt new file mode 100644 index 00000000..00fbaf49 --- /dev/null +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/decorations/ClawNavigationRail.kt @@ -0,0 +1,84 @@ +/* + * Copyright © 2022-2023 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.decorations + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade +import androidx.compose.animation.core.FastOutLinearInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.layout.Spacer +import androidx.compose.material3.Icon +import androidx.compose.material3.NavigationRail +import androidx.compose.material3.NavigationRailItem +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavController +import dev.msfjarvis.claw.android.ui.navigation.Destinations +import kotlinx.collections.immutable.ImmutableList + +@Composable +fun ClawNavigationRail( + navController: NavController, + items: ImmutableList, + isVisible: Boolean, + modifier: Modifier = Modifier, +) { + AnimatedVisibility( + visible = isVisible, + enter = + slideInVertically( + // Enters by sliding up from offset 0 to fullHeight. + initialOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween(durationMillis = AnimationDuration, easing = LinearOutSlowInEasing), + ), + exit = + slideOutVertically( + // Exits by sliding up from offset 0 to -fullHeight. + targetOffsetY = { fullHeight -> fullHeight }, + animationSpec = tween(durationMillis = AnimationDuration, easing = FastOutLinearInEasing), + ), + modifier = Modifier, + ) { + NavigationRail(modifier = modifier) { + Spacer(Modifier.weight(1f)) + items.forEach { navItem -> + val isCurrentDestination = navController.currentDestination?.route == navItem.route + NavigationRailItem( + icon = { + Crossfade(isCurrentDestination, label = "nav-label") { + Icon( + painter = if (it) navItem.selectedIcon else navItem.icon, + contentDescription = navItem.label.replaceFirstChar(Char::uppercase), + ) + } + }, + label = { Text(text = navItem.label) }, + selected = isCurrentDestination, + onClick = { + if (isCurrentDestination) { + navItem.listStateResetCallback() + return@NavigationRailItem + } + navController.graph.startDestinationRoute?.let { startDestination -> + navController.popBackStack(startDestination, false) + } + if (navItem.route != Destinations.startDestination.route) { + navController.navigate(navItem.route) + } + }, + modifier = Modifier.testTag(navItem.label.uppercase()), + ) + } + Spacer(Modifier.weight(1f)) + } + } +} diff --git a/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/NavigationType.kt b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/NavigationType.kt new file mode 100644 index 00000000..1602fe80 --- /dev/null +++ b/android/src/main/kotlin/dev/msfjarvis/claw/android/ui/navigation/NavigationType.kt @@ -0,0 +1,25 @@ +/* + * Copyright © 2023 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.navigation + +import androidx.compose.material3.windowsizeclass.WindowWidthSizeClass + +enum class ClawNavigationType { + BOTTOM_NAVIGATION, + NAVIGATION_RAIL; + + companion object { + fun fromSize(windowSize: WindowWidthSizeClass): ClawNavigationType { + return when (windowSize) { + WindowWidthSizeClass.Compact -> BOTTOM_NAVIGATION + WindowWidthSizeClass.Medium, + WindowWidthSizeClass.Expanded -> NAVIGATION_RAIL + else -> BOTTOM_NAVIGATION + } + } + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37fc8094..cf3f96ba 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -27,6 +27,7 @@ androidx-compose-foundation = { module = "androidx.compose.foundation:foundation androidx-compose-material = { module = "androidx.compose.material:material" } 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 = { group = "androidx.compose.material3", name = "material3-window-size-class" } 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" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 240dafac..adbd0186 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -109,6 +109,7 @@ dependencyResolutionManagement { includeGroup("androidx.vectordrawable") includeGroup("androidx.versionedparcelable") includeGroup("androidx.viewpager") + includeGroup("androidx.window") includeGroup("androidx.work") includeGroup("com.android") includeGroup("com.android.tools")