feat: custom bottom nav in parity with tv icons

This commit is contained in:
tapframe 2026-04-07 02:07:30 +05:30
parent ed03cee241
commit 28674704eb
10 changed files with 301 additions and 56 deletions

View file

@ -8,5 +8,6 @@ import androidx.compose.ui.unit.dp
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 6.dp
@Composable
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets.navigationBars

View file

@ -0,0 +1,18 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M18.5,3H16C15.7239,3 15.5,3.22386 15.5,3.5V3.55891L19,6.35891V3.5C19,3.22386 18.7762,3 18.5,3Z"
android:fillColor="#000000"/>
<path
android:pathData="M10.75,9.5C10.75,8.80964 11.3097,8.25 12,8.25C12.6904,8.25 13.25,8.80964 13.25,9.5C13.25,10.1904 12.6904,10.75 12,10.75C11.3097,10.75 10.75,10.1904 10.75,9.5Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
<path
android:pathData="M20.75,10.9605L21.5315,11.5857C21.855,11.8444 22.3269,11.792 22.5857,11.4685C22.8444,11.1451 22.792,10.6731 22.4685,10.4143L14.3426,3.91362C12.9731,2.81796 11.027,2.81796 9.65742,3.91362L1.53151,10.4143C1.20806,10.6731 1.15562,11.1451 1.41438,11.4685C1.67313,11.792 2.1451,11.8444 2.46855,11.5857L3.25003,10.9605V21.25H2.00003C1.58581,21.25 1.25003,21.5858 1.25003,22C1.25003,22.4142 1.58581,22.75 2.00003,22.75H22C22.4142,22.75 22.75,22.4142 22.75,22C22.75,21.5858 22.4142,21.25 22,21.25H20.75V10.9605ZM9.25003,9.5C9.25003,7.98122 10.4812,6.75 12,6.75C13.5188,6.75 14.75,7.98122 14.75,9.5C14.75,11.0188 13.5188,12.25 12,12.25C10.4812,12.25 9.25003,11.0188 9.25003,9.5ZM12.0494,13.25C12.7143,13.25 13.2871,13.2499 13.7459,13.3116C14.2375,13.3777 14.7088,13.5268 15.091,13.909C15.4733,14.2913 15.6223,14.7625 15.6884,15.2542C15.7462,15.6842 15.7498,16.2146 15.75,16.827C15.75,16.8679 15.75,16.9091 15.75,16.9506L15.75,21.25H14.25V17C14.25,16.2717 14.2484,15.8009 14.2018,15.454C14.1581,15.1287 14.0875,15.0268 14.0304,14.9697C13.9733,14.9126 13.8713,14.842 13.546,14.7982C13.1991,14.7516 12.7283,14.75 12,14.75C11.2717,14.75 10.8009,14.7516 10.4541,14.7982C10.1288,14.842 10.0268,14.9126 9.9697,14.9697C9.9126,15.0268 9.84199,15.1287 9.79826,15.454C9.75162,15.8009 9.75003,16.2717 9.75003,17V21.25H8.25003L8.25003,16.9506C8.24999,16.2858 8.24996,15.7129 8.31163,15.2542C8.37773,14.7625 8.52679,14.2913 8.90904,13.909C9.29128,13.5268 9.76255,13.3777 10.2542,13.3116C10.7129,13.2499 11.2858,13.25 11.9507,13.25H12.0494Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,17 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M8.50989,2.00001H15.49C15.7225,1.99995 15.9007,1.99991 16.0565,2.01515C17.1643,2.12352 18.0711,2.78958 18.4556,3.68678H5.54428C5.92879,2.78958 6.83555,2.12352 7.94337,2.01515C8.09917,1.99991 8.27741,1.99995 8.50989,2.00001Z"
android:fillColor="#000000"/>
<path
android:pathData="M6.31052,4.72312C4.91989,4.72312 3.77963,5.56287 3.3991,6.67691C3.39117,6.70013 3.38356,6.72348 3.37629,6.74693C3.77444,6.62636 4.18881,6.54759 4.60827,6.49382C5.68865,6.35531 7.05399,6.35538 8.64002,6.35547L8.75846,6.35547L15.5321,6.35547C17.1181,6.35538 18.4835,6.35531 19.5639,6.49382C19.9833,6.54759 20.3977,6.62636 20.7958,6.74693C20.7886,6.72348 20.781,6.70013 20.773,6.67691C20.3925,5.56287 19.2522,4.72312 17.8616,4.72312H6.31052Z"
android:fillColor="#000000"/>
<path
android:pathData="M8.67239,7.54204H15.3276C18.7024,7.54204 20.3898,7.54204 21.3377,8.52887C22.2855,9.5157 22.0625,11.0403 21.6165,14.0896L21.1935,16.9811C20.8437,19.3724 20.6689,20.568 19.7717,21.284C18.8745,22 17.5512,22 14.9046,22H9.09536C6.44881,22 5.12553,22 4.22834,21.284C3.33115,20.568 3.15626,19.3724 2.80648,16.9811L2.38351,14.0896C1.93748,11.0403 1.71447,9.5157 2.66232,8.52887C3.61017,7.54204 5.29758,7.54204 8.67239,7.54204ZM8,18.0001C8,17.5859 8.3731,17.2501 8.83333,17.2501H15.1667C15.6269,17.2501 16,17.5859 16,18.0001C16,18.4144 15.6269,18.7502 15.1667,18.7502H8.83333C8.3731,18.7502 8,18.4144 8,18.0001Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="20dp"
android:viewportWidth="20"
android:viewportHeight="20">
<path
android:pathData="M4,9a5,5 0,1 1,10 0A5,5 0,0 1,4 9zm5,-7a7,7 0,1 0,4.2 12.6 0.999,0.999 0,0 0,0.093 0.107l3,3a1,1 0,0 0,1.414 -1.414l-3,-3a0.999,0.999 0,0 0,-0.107 -0.093A7,7 0,0 0,9 2z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -0,0 +1,11 @@
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:pathData="M14.2788,2.15224C13.9085,2 13.439,2 12.5,2C11.561,2 11.0915,2 10.7212,2.15224C10.2274,2.35523 9.83509,2.74458 9.63056,3.23463C9.53719,3.45834 9.50065,3.7185 9.48635,4.09799C9.46534,4.65568 9.17716,5.17189 8.69017,5.45093C8.20318,5.72996 7.60864,5.71954 7.11149,5.45876C6.77318,5.2813 6.52789,5.18262 6.28599,5.15102C5.75609,5.08178 5.22018,5.22429 4.79616,5.5472C4.47814,5.78938 4.24339,6.1929 3.7739,6.99993C3.30441,7.80697 3.06967,8.21048 3.01735,8.60491C2.94758,9.1308 3.09118,9.66266 3.41655,10.0835C3.56506,10.2756 3.77377,10.437 4.0977,10.639C4.57391,10.936 4.88032,11.4419 4.88029,12C4.88026,12.5581 4.57386,13.0639 4.0977,13.3608C3.77372,13.5629 3.56497,13.7244 3.41645,13.9165C3.09108,14.3373 2.94749,14.8691 3.01725,15.395C3.06957,15.7894 3.30432,16.193 3.7738,17C4.24329,17.807 4.47804,18.2106 4.79606,18.4527C5.22008,18.7756 5.75599,18.9181 6.28589,18.8489C6.52778,18.8173 6.77305,18.7186 7.11133,18.5412C7.60852,18.2804 8.2031,18.27 8.69012,18.549C9.17714,18.8281 9.46533,19.3443 9.48635,19.9021C9.50065,20.2815 9.53719,20.5417 9.63056,20.7654C9.83509,21.2554 10.2274,21.6448 10.7212,21.8478C11.0915,22 11.561,22 12.5,22C13.439,22 13.9085,22 14.2788,21.8478C14.7726,21.6448 15.1649,21.2554 15.3694,20.7654C15.4628,20.5417 15.4994,20.2815 15.5137,19.902C15.5347,19.3443 15.8228,18.8281 16.3098,18.549C16.7968,18.2699 17.3914,18.2804 17.8886,18.5412C18.2269,18.7186 18.4721,18.8172 18.714,18.8488C19.2439,18.9181 19.7798,18.7756 20.2038,18.4527C20.5219,18.2105 20.7566,17.807 21.2261,16.9999C21.6956,16.1929 21.9303,15.7894 21.9827,15.395C22.0524,14.8691 21.9088,14.3372 21.5835,13.9164C21.4349,13.7243 21.2262,13.5628 20.9022,13.3608C20.4261,13.0639 20.1197,12.558 20.1197,11.9999C20.1197,11.4418 20.4261,10.9361 20.9022,10.6392C21.2263,10.4371 21.435,10.2757 21.5836,10.0835C21.9089,9.66273 22.0525,9.13087 21.9828,8.60497C21.9304,8.21055 21.6957,7.80703 21.2262,7C20.7567,6.19297 20.522,5.78945 20.2039,5.54727C19.7799,5.22436 19.244,5.08185 18.7141,5.15109C18.4722,5.18269 18.2269,5.28136 17.8887,5.4588C17.3915,5.71959 16.7969,5.73002 16.3099,5.45096C15.8229,5.17191 15.5347,4.65566 15.5136,4.09794C15.4993,3.71848 15.4628,3.45833 15.3694,3.23463C15.1649,2.74458 14.7726,2.35523 14.2788,2.15224ZM12.5,15C14.1695,15 15.5228,13.6569 15.5228,12C15.5228,10.3431 14.1695,9 12.5,9C10.8305,9 9.47716,10.3431 9.47716,12C9.47716,13.6569 10.8305,15 12.5,15Z"
android:fillColor="#000000"
android:fillType="evenOdd"/>
</vector>

View file

@ -27,9 +27,7 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Home
import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.VideoLibrary
import androidx.compose.material.icons.filled.Home
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
@ -37,9 +35,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationBarItemDefaults
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
@ -75,7 +71,7 @@ import com.nuvio.app.core.auth.AuthState
import com.nuvio.app.core.deeplink.AppDeepLink
import com.nuvio.app.core.deeplink.AppDeepLinkRepository
import com.nuvio.app.core.sync.SyncManager
import com.nuvio.app.core.ui.nuvioBottomNavigationBarInsets
import com.nuvio.app.core.ui.NuvioNavigationBar
import com.nuvio.app.core.ui.NuvioPosterActionSheet
import com.nuvio.app.core.ui.PlatformBackHandler
import com.nuvio.app.core.ui.TraktListPickerDialog
@ -145,6 +141,9 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import nuvio.composeapp.generated.resources.Res
import nuvio.composeapp.generated.resources.app_logo_wordmark
import nuvio.composeapp.generated.resources.sidebar_library
import nuvio.composeapp.generated.resources.sidebar_search
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@Serializable
@ -556,52 +555,36 @@ private fun MainAppContent(
contentWindowInsets = WindowInsets(0),
bottomBar = {
if (!isTabletLayout) {
val navigationItemColors = NavigationBarItemDefaults.colors(
selectedIconColor = MaterialTheme.colorScheme.onPrimaryContainer,
selectedTextColor = MaterialTheme.colorScheme.onPrimaryContainer,
indicatorColor = MaterialTheme.colorScheme.primaryContainer,
unselectedIconColor = MaterialTheme.colorScheme.onSurfaceVariant,
unselectedTextColor = MaterialTheme.colorScheme.onSurfaceVariant,
)
NavigationBar(
containerColor = MaterialTheme.colorScheme.surfaceVariant,
windowInsets = nuvioBottomNavigationBarInsets(),
) {
NavigationBarItem(
NuvioNavigationBar {
NavItem(
selected = selectedTab == AppScreenTab.Home,
onClick = { selectedTab = AppScreenTab.Home },
icon = { Icon(Icons.Rounded.Home, contentDescription = null) },
label = { Text("Home") },
colors = navigationItemColors,
icon = Icons.Filled.Home,
contentDescription = "Home",
)
NavigationBarItem(
NavItem(
selected = selectedTab == AppScreenTab.Search,
onClick = { selectedTab = AppScreenTab.Search },
icon = { Icon(Icons.Rounded.Search, contentDescription = null) },
label = { Text("Search") },
colors = navigationItemColors,
icon = Res.drawable.sidebar_search,
contentDescription = "Search",
)
NavigationBarItem(
NavItem(
selected = selectedTab == AppScreenTab.Library,
onClick = { selectedTab = AppScreenTab.Library },
icon = { Icon(Icons.Rounded.VideoLibrary, contentDescription = null) },
label = { Text("Library") },
colors = navigationItemColors,
icon = Res.drawable.sidebar_library,
contentDescription = "Library",
)
NavigationBarItem(
NavItem(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
icon = {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
},
label = { Text("Profile") },
colors = navigationItemColors,
)
) {
ProfileSwitcherTab(
selected = selectedTab == AppScreenTab.Settings,
onClick = { selectedTab = AppScreenTab.Settings },
onProfileSelected = onProfileSelected,
onAddProfileRequested = onSwitchProfile,
)
}
}
}
},
@ -1430,9 +1413,14 @@ private fun TabletFloatingTopBar(
onClick = { onTabSelected(AppScreenTab.Home) },
icon = {
Icon(
imageVector = Icons.Rounded.Home,
contentDescription = null,
imageVector = Icons.Filled.Home,
contentDescription = "Home",
modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Home) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
)
@ -1442,9 +1430,14 @@ private fun TabletFloatingTopBar(
onClick = { onTabSelected(AppScreenTab.Search) },
icon = {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = null,
painter = painterResource(Res.drawable.sidebar_search),
contentDescription = "Search",
modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Search) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
)
@ -1454,9 +1447,14 @@ private fun TabletFloatingTopBar(
onClick = { onTabSelected(AppScreenTab.Library) },
icon = {
Icon(
imageVector = Icons.Rounded.VideoLibrary,
contentDescription = null,
painter = painterResource(Res.drawable.sidebar_library),
contentDescription = "Library",
modifier = Modifier.size(18.dp),
tint = if (selectedTab == AppScreenTab.Library) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
},
)
},
)

View file

@ -0,0 +1,178 @@
package com.nuvio.app.core.ui
import androidx.compose.animation.animateColorAsState
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.selection.selectable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.unit.dp
import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource
@Composable
fun NuvioNavigationBar(
modifier: Modifier = Modifier,
content: @Composable NuvioNavigationBarScope.() -> Unit,
) {
Column(modifier.fillMaxWidth()) {
HorizontalDivider(
thickness = 0.5.dp,
color = MaterialTheme.colorScheme.outlineVariant,
)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(nuvioBottomNavigationBarInsets().asPaddingValues())
.padding(horizontal = 4.dp, vertical = nuvioBottomNavigationExtraVerticalPadding),
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.CenterHorizontally),
) {
NuvioNavigationBarScopeImpl(this).content()
}
}
}
interface NuvioNavigationBarScope {
@Composable
fun NavItem(
selected: Boolean,
onClick: () -> Unit,
icon: ImageVector,
contentDescription: String?,
modifier: Modifier = Modifier,
)
@Composable
fun NavItem(
selected: Boolean,
onClick: () -> Unit,
icon: DrawableResource,
contentDescription: String?,
modifier: Modifier = Modifier,
)
@Composable
fun NavItem(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit,
)
}
private class NuvioNavigationBarScopeImpl(
private val rowScope: androidx.compose.foundation.layout.RowScope,
) : NuvioNavigationBarScope {
@Composable
override fun NavItem(
selected: Boolean,
onClick: () -> Unit,
icon: ImageVector,
contentDescription: String?,
modifier: Modifier,
) {
val iconColor by animateColorAsState(
targetValue = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
with(rowScope) {
Icon(
modifier = modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp)
.size(28.dp),
imageVector = icon,
contentDescription = contentDescription,
tint = iconColor,
)
}
}
@Composable
override fun NavItem(
selected: Boolean,
onClick: () -> Unit,
icon: DrawableResource,
contentDescription: String?,
modifier: Modifier,
) {
val iconColor by animateColorAsState(
targetValue = if (selected) MaterialTheme.colorScheme.primary
else MaterialTheme.colorScheme.onSurfaceVariant,
)
with(rowScope) {
Icon(
modifier = modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp)
.size(28.dp),
painter = painterResource(icon),
contentDescription = contentDescription,
tint = iconColor,
)
}
}
@Composable
override fun NavItem(
selected: Boolean,
onClick: () -> Unit,
modifier: Modifier,
content: @Composable () -> Unit,
) {
with(rowScope) {
Box(
modifier = modifier
.widthIn(max = 150.dp)
.fillMaxWidth()
.weight(1f, fill = false)
.clip(RoundedCornerShape(16.dp))
.selectable(
selected = selected,
enabled = true,
role = Role.Tab,
onClick = onClick,
)
.padding(10.dp),
contentAlignment = Alignment.Center,
) {
content()
}
}
}
}

View file

@ -6,5 +6,6 @@ import androidx.compose.ui.unit.Dp
internal expect val nuvioPlatformExtraTopPadding: Dp
internal expect val nuvioPlatformExtraBottomPadding: Dp
internal expect val nuvioBottomNavigationExtraVerticalPadding: Dp
@Composable
internal expect fun nuvioBottomNavigationBarInsets(): WindowInsets

View file

@ -73,6 +73,7 @@ fun ProfileSwitcherTab(
onClick: () -> Unit,
onProfileSelected: (NuvioProfile) -> Unit,
onAddProfileRequested: () -> Unit,
triggerContent: (@Composable (selected: Boolean) -> Unit)? = null,
modifier: Modifier = Modifier,
) {
val profileState by ProfileRepository.state.collectAsStateWithLifecycle()
@ -144,13 +145,16 @@ fun ProfileSwitcherTab(
},
contentAlignment = Alignment.Center,
) {
// The avatar icon for the bottom navigation
ActiveProfileMiniAvatar(
profile = activeProfile,
avatars = avatars,
selected = selected,
size = 28,
)
if (triggerContent != null) {
triggerContent(selected)
} else {
ActiveProfileMiniAvatar(
profile = activeProfile,
avatars = avatars,
selected = selected,
size = 28,
)
}
// Floating profile popup (stays composed during exit animation)
if (popupVisible && profiles.isNotEmpty()) {

View file

@ -1,11 +1,17 @@
package com.nuvio.app.core.ui
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
internal actual val nuvioPlatformExtraTopPadding: Dp = 0.dp
internal actual val nuvioPlatformExtraBottomPadding: Dp = 0.dp
internal actual val nuvioBottomNavigationExtraVerticalPadding: Dp = 0.dp
@Composable
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets = WindowInsets(0)
internal actual fun nuvioBottomNavigationBarInsets(): WindowInsets =
WindowInsets.safeDrawing.only(WindowInsetsSides.Bottom)