diff --git a/app/src/main/kotlin/com/example/myapplication/MainApp.kt b/app/src/main/kotlin/com/example/myapplication/MainApp.kt index 5c763e7..75151aa 100644 --- a/app/src/main/kotlin/com/example/myapplication/MainApp.kt +++ b/app/src/main/kotlin/com/example/myapplication/MainApp.kt @@ -195,52 +195,56 @@ fun MainApp() { Box( Modifier .fillMaxSize() - .then(if (showBottomBar && glassBackdrop != null) Modifier.layerBackdrop(glassBackdrop) else Modifier), + // Keep the page background inside the same capture layer + // used by the bottom bar. This makes transparent page + // regions render consistently through Backdrop as well. + .background(backgroundBrush) + .then(if (glassBackdrop != null) Modifier.layerBackdrop(glassBackdrop) else Modifier), ) { androidx.compose.runtime.CompositionLocalProvider(LocalGlassBackdrop provides null) { - AppNavHost( - navController = navController, - servers = servers, - selectedServer = selectedServer, - iconLibraries = iconLibraries, - onSelectServer = selectServer, - onAddServer = addServer, - onDeleteServer = { id -> - coroutineScope.launch { ServerRepository.deleteServer(id) } - if (selectedServerId == id) selectedServerId = "" - }, - onUpdateServer = updateServer, - changeIconTarget = changeIconTarget, - onDismissChangeIcon = { changeIconTarget = null }, - onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, - onRequestChangeIcon = { server -> changeIconTarget = server }, - onSwitchServerAddress = { serverId, addressId -> - coroutineScope.launch { - ServerRepository.setActiveAddress(serverId, addressId) - ServerRepository.checkServerOnline(serverId) - } - }, - onAddIconLibrary = { name, url, icons, isDefault -> - val library = IconLibrary( - id = "lib_${System.currentTimeMillis()}", - name = name, - sourceUrl = url, - iconCount = icons.size, - isDefault = isDefault, - icons = icons, - ) - coroutineScope.launch { IconLibraryRepository.addLibrary(library) } - }, - onUpdateIconLibrary = { library -> - coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } - }, - onDeleteIconLibrary = { id -> - coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } - }, - onRefreshIconLibrary = { id -> - coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } - }, - ) + AppNavHost( + navController = navController, + servers = servers, + selectedServer = selectedServer, + iconLibraries = iconLibraries, + onSelectServer = selectServer, + onAddServer = addServer, + onDeleteServer = { id -> + coroutineScope.launch { ServerRepository.deleteServer(id) } + if (selectedServerId == id) selectedServerId = "" + }, + onUpdateServer = updateServer, + changeIconTarget = changeIconTarget, + onDismissChangeIcon = { changeIconTarget = null }, + onPickIcon = { server, iconUrl -> ServerRepository.setRuntimeIcon(server.id, iconUrl) }, + onRequestChangeIcon = { server -> changeIconTarget = server }, + onSwitchServerAddress = { serverId, addressId -> + coroutineScope.launch { + ServerRepository.setActiveAddress(serverId, addressId) + ServerRepository.checkServerOnline(serverId) + } + }, + onAddIconLibrary = { name, url, icons, isDefault -> + val library = IconLibrary( + id = "lib_${System.currentTimeMillis()}", + name = name, + sourceUrl = url, + iconCount = icons.size, + isDefault = isDefault, + icons = icons, + ) + coroutineScope.launch { IconLibraryRepository.addLibrary(library) } + }, + onUpdateIconLibrary = { library -> + coroutineScope.launch { IconLibraryRepository.updateLibrary(library) } + }, + onDeleteIconLibrary = { id -> + coroutineScope.launch { IconLibraryRepository.removeLibrary(id) } + }, + onRefreshIconLibrary = { id -> + coroutineScope.launch { IconLibraryRepository.refreshLibrary(id) } + }, + ) } } diff --git a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt index c774d88..ac4662b 100644 --- a/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt +++ b/app/src/main/kotlin/com/example/myapplication/navigation/AppNavHost.kt @@ -4,6 +4,8 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -16,6 +18,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -45,6 +48,9 @@ import com.example.myapplication.screens.SettingsScreen import com.example.myapplication.screens.SettingsTopBar import com.example.myapplication.screens.VideoPlayerScreen import com.example.myapplication.ui.design.LumaLoading +import com.example.myapplication.ui.design.LumaColors +import com.example.myapplication.ui.design.rememberLumaLayerBackdrop +import com.kyant.backdrop.backdrops.layerBackdrop import kotlinx.coroutines.CancellationException import top.yukonga.miuix.kmp.basic.Text @@ -117,21 +123,36 @@ fun AppNavHost( composable(AppRoute.Records.pattern) { var segment by rememberSaveable { mutableStateOf(0) } - Column(Modifier.fillMaxSize()) { - RecordsTopBar(selected = segment, onSelect = { segment = it }) - Box(Modifier.weight(1f)) { - RecordsScreen( - segment = segment, - servers = servers, - activeServer = selectedServer, - onOpenDetail = { serverId, itemId -> openDetail(serverId, itemId) }, - onViewAll = { serverId, type -> - navController.navigate(AppRoute.FavoritesAll.create(serverId, type)) { - launchSingleTop = true - } - }, - ) + val recordsBackdrop = rememberLumaLayerBackdrop() + Box(Modifier.fillMaxSize()) { + Box( + Modifier + .fillMaxSize() + .background(LumaColors.background) + .then(if (recordsBackdrop != null) Modifier.layerBackdrop(recordsBackdrop) else Modifier), + ) { + Column(Modifier.fillMaxSize()) { + Spacer(Modifier.height(120.dp)) + Box(Modifier.weight(1f)) { + RecordsScreen( + segment = segment, + servers = servers, + activeServer = selectedServer, + onOpenDetail = { serverId, itemId -> openDetail(serverId, itemId) }, + onViewAll = { serverId, type -> + navController.navigate(AppRoute.FavoritesAll.create(serverId, type)) { + launchSingleTop = true + } + }, + ) + } + } } + RecordsTopBar( + selected = segment, + backdrop = recordsBackdrop, + onSelect = { segment = it }, + ) } } diff --git a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt index e513115..3a9c9d1 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/HomeScreen.kt @@ -77,6 +77,7 @@ import com.example.myapplication.data.EmbyServer import com.example.myapplication.ui.GlassDialog import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassPopupOverlay +import com.example.myapplication.ui.LumaEmptyStateContainer import com.example.myapplication.ui.PosterImmersivePalette import com.example.myapplication.ui.rememberPosterImmersiveBackground import com.example.myapplication.ui.bottomBarContentPadding @@ -84,7 +85,7 @@ import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaAsyncImage import com.example.myapplication.ui.design.LumaMediaCard -import com.example.myapplication.ui.design.LumaEmptyState +import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaLoading import com.example.myapplication.ui.design.LumaMotion @@ -730,6 +731,8 @@ fun HomeScreen( val homeListState = rememberLazyListState() val activeHeroItem = heroItems.getOrNull(heroPagerState.currentPage) val immersiveBackground = rememberPosterImmersiveBackground(heroImageUrl(activeHeroItem, activeServer?.fullUrl.orEmpty())) + val hasHomeContent = heroItems.isNotEmpty() || resumeItems.isNotEmpty() || libraries.isNotEmpty() + val useImmersiveHomeBackground = activeServer != null && hasHomeContent LaunchedEffect(homeKey, reloadToken) { val server = activeServer @@ -779,9 +782,32 @@ fun HomeScreen( } } - val adaptiveText = if (immersiveBackground.isDark) Color.White else Color.Black - val adaptiveSecondary = adaptiveText.copy(alpha = 0.7f) - val adaptiveAccent = if (immersiveBackground.isDark) Color(0xFF6EA8FF) else Color(0xFF0061A4) + val adaptiveText = if (useImmersiveHomeBackground) { + if (immersiveBackground.isDark) Color.White else Color.Black + } else { + LumaColors.text + } + val adaptiveSecondary = if (useImmersiveHomeBackground) adaptiveText.copy(alpha = 0.7f) else LumaColors.textSecondary + val adaptiveAccent = if (useImmersiveHomeBackground) { + if (immersiveBackground.isDark) Color(0xFF6EA8FF) else Color(0xFF0061A4) + } else { + LumaColors.accent + } + val homeBackground = if (useImmersiveHomeBackground) { + Brush.verticalGradient( + 0f to immersiveBackground.pageTop, + 0.54f to immersiveBackground.pageTop, + 1f to immersiveBackground.pageBottom, + ) + } else { + Brush.linearGradient( + listOf( + LumaColors.background, + LumaColors.backgroundElevated, + LumaColors.background, + ), + ) + } CompositionLocalProvider( LocalHomeContentColor provides adaptiveText, @@ -796,13 +822,7 @@ fun HomeScreen( Box( Modifier .fillMaxSize() - .background( - Brush.verticalGradient( - 0f to immersiveBackground.pageTop, - 0.54f to immersiveBackground.pageTop, - 1f to immersiveBackground.pageBottom, - ) - ) + .background(homeBackground) .then(if (homeBackdrop != null) Modifier.layerBackdrop(homeBackdrop) else Modifier), ) { AnimatedContent( @@ -813,9 +833,11 @@ fun HomeScreen( label = "HomeLoadingTransition" ) { isLoading -> if (activeServer == null) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - EmptyHomeState() - } + LumaEmptyStateContainer( + icon = LumaIcon.Server, + title = stringResource(Res.string.empty_home_no_server), + description = stringResource(Res.string.empty_home_no_server_sub), + ) } else if (isLoading) { Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { LumaLoading(label = stringResource(Res.string.loading_home)) @@ -829,12 +851,18 @@ fun HomeScreen( onRetry = { reloadToken++ }, ) } + } else if (!hasHomeContent) { + LumaEmptyStateContainer( + icon = LumaIcon.Media, + title = stringResource(Res.string.empty_home_no_data), + description = stringResource(Res.string.empty_home_no_data_sub), + ) } else { val server = activeServer LazyColumn( state = homeListState, modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()) + contentPadding = PaddingValues(bottom = bottomBarContentPadding().calculateBottomPadding()) ) { if (heroItems.isNotEmpty()) { item { @@ -856,18 +884,6 @@ fun HomeScreen( .padding(start = LumaSpacing.md, end = LumaSpacing.md, top = LumaSpacing.md), verticalArrangement = Arrangement.spacedBy(LumaSpacing.xl), ) { - if (resumeItems.isEmpty() && latestItems.isEmpty() && libraries.isEmpty()) { - Box( - Modifier.fillMaxWidth().padding(vertical = LumaSpacing.xxl), - contentAlignment = Alignment.Center, - ) { - LumaEmptyState( - icon = LumaIcon.Media, - title = stringResource(Res.string.empty_home_no_data), - description = stringResource(Res.string.empty_home_no_data_sub), - ) - } - } if (resumeItems.isNotEmpty()) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { SectionTitle(title = stringResource(Res.string.continue_watching)) @@ -954,25 +970,6 @@ fun HomeScreen( } } -@Composable -private fun EmptyHomeState() { - Column( - Modifier.fillMaxWidth().padding(horizontal = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - Box( - Modifier.size(64.dp).clip(CircleShape).background(homeSecondaryColor().copy(alpha = 0.16f)), - contentAlignment = Alignment.Center, - ) { - LumaIconView(LumaIcon.Media, contentDescription = null, tint = homeSecondaryColor(), modifier = Modifier.size(32.dp)) - } - Spacer(Modifier.height(16.dp)) - Text(stringResource(Res.string.empty_home_no_server), color = homeSecondaryColor(), fontSize = 15.sp) - Spacer(Modifier.height(6.dp)) - Text(stringResource(Res.string.empty_home_no_server_sub), color = homeSecondaryColor().copy(alpha = 0.72f), fontSize = 12.sp) - } -} - @Composable private fun HomeOverlayTopBar( server: EmbyServer?, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt index b87f9e5..62cb6a2 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/LibraryScreen.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.gestures.detectHorizontalDragGestures import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size @@ -46,6 +47,7 @@ import com.example.myapplication.ui.GlassSheet import com.example.myapplication.ui.GlassInputField import com.example.myapplication.ui.GlassPopupItem import com.example.myapplication.ui.LiquidGlassButton +import com.example.myapplication.ui.LumaEmptyStateContainer import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect @@ -100,7 +102,6 @@ import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.rememberLumaLayerBackdrop -import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaAsyncImage @@ -119,6 +120,7 @@ private val serverTints = listOf( ) private val onlineGreen = Color(0xFF34C759) private val offlineRed = Color(0xFFFF3B30) +private val iconLibraryControlHeight = 48.dp @Composable fun LibraryTopBar( @@ -382,25 +384,16 @@ fun LibraryScreen( ) CompositionLocalProvider(LocalGlassBackdrop provides libraryBackdrop) { if (servers.isEmpty()) { - Box( - Modifier.fillMaxSize().padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), - contentAlignment = Alignment.Center, - ) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - ) { - LumaEmptyState( - icon = LumaIcon.Server, - title = stringResource(Res.string.empty_servers), - description = stringResource(Res.string.empty_servers_sub), - ) - } - } + LumaEmptyStateContainer( + icon = LumaIcon.Server, + title = stringResource(Res.string.empty_servers), + description = stringResource(Res.string.empty_servers_sub), + ) } else { LazyVerticalGrid( columns = if (isGrid) GridCells.Adaptive(minSize = 150.dp) else GridCells.Fixed(1), modifier = Modifier.fillMaxSize().statusBarsPadding().padding(horizontal = 16.dp).padding(top = 78.dp), - contentPadding = PaddingValues(bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), + contentPadding = PaddingValues(bottom = bottomBarContentPadding().calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(if (isGrid) 12.dp else 10.dp), horizontalArrangement = Arrangement.spacedBy(12.dp), ) { @@ -536,12 +529,6 @@ fun LibraryScreen( } } -private fun managementDialogSurfaceColor(): Color = if (GlassTokens.isDark) { - Color(0xFF111923).copy(alpha = 0.92f) -} else { - Color(0xFFDDE7F1).copy(alpha = 0.94f) -} - @Composable private fun ServerAddressPickerDialog( server: EmbyServer, @@ -726,6 +713,9 @@ internal fun IconLibraryManagerScreenContent( Box(Modifier.width(86.dp)) { Box( Modifier.size(48.dp) + .clip(CircleShape) + .background(LumaColors.floatingTint) + .border(0.6.dp, LumaColors.glassBorder, CircleShape) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, @@ -733,13 +723,7 @@ internal fun IconLibraryManagerScreenContent( ) { onBack() }, contentAlignment = Alignment.Center, ) { - Box( - Modifier.size(34.dp) - .lumaGlass(radius = 17.dp, level = com.example.myapplication.ui.design.LumaGlassLevel.Floating), - contentAlignment = Alignment.Center, - ) { - LumaIconView(LumaIcon.Close, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(19.dp)) - } + LumaIconView(LumaIcon.Back, contentDescription = stringResource(Res.string.back), tint = GlassTokens.text, modifier = Modifier.size(20.dp)) } } Box(Modifier.weight(1f), contentAlignment = Alignment.Center) { @@ -958,7 +942,7 @@ private fun ManageIconLibraryDialog( backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.width(320.dp), - surfaceOverlay = managementDialogSurfaceColor(), + surfaceOverlay = Color.White.copy(alpha = 0.94f), ) { Column( modifier = Modifier.fillMaxWidth() @@ -988,8 +972,8 @@ private fun ManageIconLibraryDialog( } // Editable library metadata - GlassInputField(label = stringResource(Res.string.icon_lib_name_label), value = editName, placeholder = stringResource(Res.string.icon_lib_name_label), onValueChange = { editName = it }, containerTint = Color.Transparent, useBackdropGlass = false) - GlassInputField(label = stringResource(Res.string.icon_lib_url_label), value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it }, containerTint = Color.Transparent, useBackdropGlass = false) + GlassInputField(label = stringResource(Res.string.icon_lib_name_label), value = editName, placeholder = stringResource(Res.string.icon_lib_name_label), onValueChange = { editName = it }, containerTint = Color.Transparent, useBackdropGlass = false, fieldHeight = iconLibraryControlHeight) + GlassInputField(label = stringResource(Res.string.icon_lib_url_label), value = editUrl, placeholder = "https://...", onValueChange = { editUrl = it }, containerTint = Color.Transparent, useBackdropGlass = false, fieldHeight = iconLibraryControlHeight) // Default library switch Row( @@ -998,7 +982,8 @@ private fun ManageIconLibraryDialog( .background(if (setAsDefault) LumaColors.accentSoft else Color.Transparent) .border(0.7.dp, if (setAsDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, RoundedCornerShape(16.dp)) .clickable { setAsDefault = !setAsDefault } - .padding(horizontal = 14.dp, vertical = 10.dp), + .height(iconLibraryControlHeight) + .padding(horizontal = 14.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { @@ -1113,7 +1098,7 @@ private fun AddIconLibraryDialog( backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.width(320.dp), - surfaceOverlay = managementDialogSurfaceColor(), + surfaceOverlay = Color.White.copy(alpha = 0.94f), ) { Box(Modifier.fillMaxWidth()) { Column( @@ -1139,6 +1124,7 @@ private fun AddIconLibraryDialog( onValueChange = { name = it }, containerTint = Color.Transparent, useBackdropGlass = false, + fieldHeight = iconLibraryControlHeight, ) // Default library switch @@ -1148,7 +1134,8 @@ private fun AddIconLibraryDialog( .background(if (isDefault) LumaColors.accentSoft else Color.Transparent) .border(0.7.dp, if (isDefault) GlassTokens.accent.copy(alpha = 0.35f) else LumaColors.glassBorder, RoundedCornerShape(16.dp)) .clickable { isDefault = !isDefault } - .padding(horizontal = 14.dp, vertical = 10.dp), + .height(iconLibraryControlHeight) + .padding(horizontal = 14.dp, vertical = 7.dp), verticalAlignment = Alignment.CenterVertically, ) { Column(Modifier.weight(1f)) { @@ -1179,6 +1166,7 @@ private fun AddIconLibraryDialog( }, containerTint = Color.Transparent, useBackdropGlass = false, + fieldHeight = iconLibraryControlHeight, ) LiquidGlassButton( @@ -1295,7 +1283,13 @@ private fun IconPickerDialog( } } - GlassSheet(backdrop = backdrop, onDismissRequest = onDismiss, modifier = Modifier.widthIn(max = 420.dp)) { + GlassSheet( + backdrop = backdrop, + onDismissRequest = onDismiss, + modifier = Modifier.widthIn(max = 420.dp), + surfaceOverlay = Color.White.copy(alpha = 0.94f), + horizontalMargin = 16.dp, + ) { Box(Modifier.fillMaxWidth()) { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -1316,11 +1310,12 @@ private fun IconPickerDialog( if (!isLoading && allIcons.isNotEmpty()) { Box( Modifier.fillMaxWidth() + .height(iconLibraryControlHeight) .lumaGlass(radius = 14.dp, level = LumaGlassLevel.Regular, shadowElevation = 0.dp) - .padding(horizontal = 12.dp, vertical = 9.dp), + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxSize(), verticalAlignment = Alignment.CenterVertically, ) { LumaIconView( @@ -1330,7 +1325,10 @@ private fun IconPickerDialog( modifier = Modifier.size(18.dp), ) Spacer(Modifier.width(8.dp)) - Box(Modifier.weight(1f)) { + Box( + Modifier.weight(1f).fillMaxHeight(), + contentAlignment = Alignment.CenterStart, + ) { if (query.text.isEmpty()) { Text( text = stringResource(Res.string.icon_picker_search_hint), @@ -1351,7 +1349,7 @@ private fun IconPickerDialog( Spacer(Modifier.width(8.dp)) Box( Modifier - .size(48.dp) + .size(36.dp) .clickable( indication = null, interactionSource = remember { MutableInteractionSource() }, diff --git a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt index 964997f..98ccd35 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/RecordsScreen.kt @@ -62,22 +62,29 @@ import com.example.myapplication.data.WatchlistEntity import com.example.myapplication.data.WatchlistRepository import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar -import com.example.myapplication.ui.SegmentedControl +import com.example.myapplication.ui.LumaEmptyStateContainer import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaColors -import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaLoading import com.example.myapplication.ui.design.LumaShapes +import com.example.myapplication.ui.liquidglass.LiquidBottomTab +import com.example.myapplication.ui.liquidglass.LiquidBottomTabs +import com.kyant.backdrop.Backdrop import top.yukonga.miuix.kmp.basic.Text @Composable fun RecordsTopBar( selected: Int, + backdrop: Backdrop? = null, modifier: Modifier = Modifier, onSelect: (Int) -> Unit, ) { + val labels = listOf( + stringResource(Res.string.records_fav), + stringResource(Res.string.records_calendar), + ) LiquidGlassTopBar(modifier) { Box( Modifier @@ -85,12 +92,41 @@ fun RecordsTopBar( .padding(horizontal = 16.dp, vertical = 12.dp), contentAlignment = Alignment.Center, ) { - SegmentedControl( - items = listOf(stringResource(Res.string.records_fav), stringResource(Res.string.records_calendar)), - selected = selected, - onSelect = onSelect, + LiquidBottomTabs( + selectedTabIndex = { selected.coerceIn(0, labels.lastIndex) }, + onTabSelected = onSelect, + backdrop = backdrop, + tabsCount = labels.size, modifier = Modifier.width(220.dp), - ) + height = 56.dp, + surfaceAlpha = 0.16f, + pressHighlight = false, + ghostContent = {}, + ) { + labels.forEachIndexed { index, label -> + LiquidBottomTab(onClick = { onSelect(index) }) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + LumaIconView( + icon = if (index == 0) LumaIcon.Favorite else LumaIcon.Calendar, + selected = selected == index, + tint = if (selected == index) LumaColors.accent else LumaColors.textSecondary, + contentDescription = null, + modifier = Modifier.size(18.dp), + ) + Spacer(Modifier.width(6.dp)) + Text( + text = label, + color = if (selected == index) LumaColors.accent else LumaColors.textSecondary, + fontSize = 14.sp, + fontWeight = if (selected == index) FontWeight.SemiBold else FontWeight.Medium, + maxLines = 1, + ) + } + } + } + } } } } @@ -133,12 +169,6 @@ fun RecordsScreen( ) { selectedSegment -> if (selectedSegment == 1) { CalendarTab(onEpisodeClick = onOpenDetail) - } else if (servers.isEmpty()) { - RecordsEmptyState( - icon = LumaIcon.Search, - title = stringResource(Res.string.search_no_server), - subtitle = stringResource(Res.string.search_no_server_sub), - ) } else if (favoritesByServer.isEmpty()) { RecordsEmptyState( icon = LumaIcon.Favorite, @@ -151,7 +181,7 @@ fun RecordsScreen( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( top = 8.dp, - bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(), + bottom = bottomBarContentPadding().calculateBottomPadding(), ), ) { favoritesByServer.forEach { (server, serverFavs) -> @@ -399,14 +429,12 @@ private fun RecordsEmptyState( title: String, subtitle: String, ) { - Box( - Modifier - .fillMaxSize() - .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), - contentAlignment = Alignment.Center, - ) { - LumaEmptyState(icon = icon, title = title, description = subtitle) - } + LumaEmptyStateContainer( + icon = icon, + title = title, + description = subtitle, + verticalOffset = (-60).dp, + ) } // ─── Calendar Tab ─────────────────────────────────────────────────────────── @@ -475,20 +503,18 @@ private fun CalendarTab(onEpisodeClick: (String, String) -> Unit = { _, _ -> }) calendarEpisodes } if (filteredEpisodes.isEmpty()) { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaEmptyState( - icon = LumaIcon.Calendar, - title = stringResource(Res.string.empty_calendar), - description = stringResource(Res.string.empty_calendar_sub), - showIconBackground = false, - ) - } + LumaEmptyStateContainer( + icon = LumaIcon.Calendar, + title = stringResource(Res.string.empty_calendar), + description = stringResource(Res.string.empty_calendar_sub), + verticalOffset = (-60).dp, + ) } else { val groupedByDate = filteredEpisodes.groupBy { it.airDate }.toSortedMap() LazyColumn( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( - bottom = 16.dp + bottomBarContentPadding(86.dp).calculateBottomPadding(), + bottom = 16.dp + bottomBarContentPadding().calculateBottomPadding(), ), ) { groupedByDate.forEach { (dateStr, episodes) -> diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt index 375f78b..6d44de1 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SearchScreen.kt @@ -57,9 +57,9 @@ import com.example.myapplication.data.EmbyServer import com.example.myapplication.ui.GlassSearchField import com.example.myapplication.ui.GlassTokens import com.example.myapplication.ui.LiquidGlassTopBar +import com.example.myapplication.ui.LumaEmptyStateContainer import com.example.myapplication.ui.bottomBarContentPadding import com.example.myapplication.ui.design.LumaColors -import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaErrorState import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView @@ -328,8 +328,7 @@ fun SearchScreen( Box( Modifier - .weight(1f) - .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), + .weight(1f), ) { when { // No servers configured @@ -367,14 +366,12 @@ fun SearchScreen( } // Search complete: all servers returned empty allEmpty -> { - Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { - LumaEmptyState( - icon = LumaIcon.Search, - title = stringResource(Res.string.search_no_result_title), - description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix), - showIconBackground = false, - ) - } + LumaEmptyStateContainer( + icon = LumaIcon.Search, + title = stringResource(Res.string.search_no_result_title), + description = stringResource(Res.string.search_no_result_prefix) + query.trim() + stringResource(Res.string.search_no_result_suffix), + verticalOffset = (-58).dp, + ) } // Has results (even if some are still loading or errored) else -> ServerResultsList( @@ -410,7 +407,7 @@ private fun SearchHistoryList( ) { LazyColumn( modifier = Modifier.fillMaxSize(), - contentPadding = PaddingValues(top = 8.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), + contentPadding = PaddingValues(top = 8.dp, bottom = bottomBarContentPadding().calculateBottomPadding()), ) { item(key = "header") { Row( @@ -493,7 +490,7 @@ private fun ServerResultsList( modifier = Modifier.fillMaxSize(), contentPadding = PaddingValues( top = 8.dp, - bottom = bottomBarContentPadding(86.dp).calculateBottomPadding(), + bottom = bottomBarContentPadding().calculateBottomPadding(), ), verticalArrangement = Arrangement.spacedBy(4.dp), ) { @@ -718,14 +715,12 @@ private fun ServerErrorSection(server: EmbyServer) { @Composable private fun SearchEmptyState(title: String, subtitle: String) { - Box( - Modifier - .fillMaxSize() - .padding(bottom = bottomBarContentPadding(90.dp).calculateBottomPadding()), - contentAlignment = Alignment.Center, - ) { - LumaEmptyState(icon = LumaIcon.Search, title = title, description = subtitle, showIconBackground = false) - } + LumaEmptyStateContainer( + icon = LumaIcon.Search, + title = title, + description = subtitle, + verticalOffset = (-58).dp, + ) } // ─── Media Poster Card ────────────────────────────────────────────────────── diff --git a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt index 78533fc..1fcfa5b 100644 --- a/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt +++ b/app/src/main/kotlin/com/example/myapplication/screens/SettingsScreen.kt @@ -110,7 +110,7 @@ fun SettingsScreen( Modifier .fillMaxSize() .verticalScroll(rememberScrollState()) - .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding(86.dp).calculateBottomPadding()), + .padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = bottomBarContentPadding().calculateBottomPadding()), verticalArrangement = Arrangement.spacedBy(16.dp), ) { LumaSectionLabel(stringResource(R.string.settings_appearance_section)) diff --git a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt index bc29bca..d1d161a 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/AppBottomBar.kt @@ -1,9 +1,5 @@ package com.example.myapplication.ui -import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.interaction.collectIsPressedAsState import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -12,45 +8,21 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment -import androidx.compose.runtime.remember -import androidx.compose.animation.core.animateFloatAsState -import androidx.compose.animation.core.spring import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.example.myapplication.ui.liquidglass.LiquidBottomTab import com.example.myapplication.ui.liquidglass.LiquidBottomTabs import com.example.myapplication.ui.design.LumaColors -import com.example.myapplication.ui.design.LumaGlassRuntime import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaAnimatedIcon -import com.example.myapplication.ui.design.LumaIconView import com.example.myapplication.ui.design.LumaSpacing -import com.example.myapplication.ui.design.lumaFallbackGlass import com.kyant.backdrop.Backdrop -import com.kyant.backdrop.backdrops.layerBackdrop -import com.kyant.backdrop.backdrops.rememberCombinedBackdrop -import com.kyant.backdrop.backdrops.rememberLayerBackdrop -import com.kyant.backdrop.drawBackdrop -import com.kyant.backdrop.effects.blur -import com.kyant.backdrop.effects.lens -import com.kyant.backdrop.effects.vibrancy -import com.kyant.backdrop.highlight.Highlight -import com.kyant.backdrop.shadow.InnerShadow -import com.kyant.backdrop.shadow.Shadow -import com.kyant.shapes.Capsule import top.yukonga.miuix.kmp.basic.Text @Composable @@ -72,15 +44,12 @@ fun LiquidAppBottomBar( Box(modifier = Modifier.weight(1f)) { LiquidBottomTabs( selectedTabIndex = { selectedTab.coerceIn(0, 3) }, - onTabSelected = { index -> - if (selectedTab < 4) onSelect(index) - }, + onTabSelected = onSelect, backdrop = backdrop, tabsCount = 4, showSelection = { selectedTab < 4 }, ) { - val tabsCount = mainTabs.size - for (index in 0 until tabsCount) { + for (index in mainTabs.indices) { val tab = mainTabs[index] LiquidBottomTab(onClick = { onSelect(index) }) { LumaAnimatedIcon( @@ -105,156 +74,23 @@ fun LiquidAppBottomBar( Spacer(Modifier.width(LumaSpacing.sm)) - val searchSelected = selectedTab == 4 - val safeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop } - val searchContentBackdrop = rememberLayerBackdrop() - val searchInteraction = remember { MutableInteractionSource() } - val searchPressed by searchInteraction.collectIsPressedAsState() - val searchPressProgress by animateFloatAsState( - targetValue = if (searchPressed) 1f else 0f, - animationSpec = spring(dampingRatio = 0.62f, stiffness = 420f), - label = "bottomSearchPressProgress", - ) - val searchScale by animateFloatAsState( - targetValue = if (searchPressed) 1.12f else 1f, - animationSpec = spring(dampingRatio = 0.72f, stiffness = 650f), - label = "bottomSearchPress", - ) - Box( - modifier = Modifier - .size(width = 64.dp, height = 64.dp) - .graphicsLayer { - scaleX = searchScale - scaleY = searchScale - } - .clip(CircleShape) - .then( - if (safeBackdrop != null) { - Modifier.drawBackdrop( - backdrop = safeBackdrop, - shape = { Capsule() }, - effects = { - vibrancy() - blur(8f.dp.toPx()) - lens(24f.dp.toPx(), 24f.dp.toPx()) - }, - highlight = { Highlight.Default.copy(alpha = searchPressProgress) }, - shadow = { Shadow(alpha = searchPressProgress) }, - onDrawSurface = { - drawRect( - if (LumaColors.isDark) { - Color(0xFF121212).copy(alpha = 0.40f) - } else { - Color(0xFFFAFAFA).copy(alpha = 0.40f) - }, - ) - }, - ) - } else { - Modifier.lumaFallbackGlass( - shape = CircleShape, - selected = searchSelected, - neutralVeil = if (LumaColors.isDark) { - Color(0xFF1C2430).copy(alpha = 0.72f) - } else if (AppTheme.glassMode == GlassMode.Liquid) { - Color(0xFFD7E3F0).copy(alpha = 0.58f) - } else { - Color(0xFFD4D9DF).copy(alpha = 0.84f) - }, - ) - }, - ) - .then( - if (safeBackdrop != null) { - Modifier.border( - 0.6.dp, - if (searchSelected) LumaColors.glassBorder else LumaColors.glassBorder.copy(alpha = 0.72f), - CircleShape, - ) - } else { - Modifier - }, - ) - .clickable(indication = null, interactionSource = searchInteraction) { onSelect(4) }, - contentAlignment = Alignment.Center, + LiquidBottomTabs( + selectedTabIndex = { 0 }, + onTabSelected = { onSelect(4) }, + backdrop = backdrop, + tabsCount = 1, + showSelection = { selectedTab == 4 }, + modifier = Modifier.size(64.dp), ) { - if (safeBackdrop != null) { - Box( - Modifier - .clearAndSetSemantics {} - .alpha(0f) - .layerBackdrop(searchContentBackdrop) - .size(56.dp) - .graphicsLayer(colorFilter = ColorFilter.tint(active)), - contentAlignment = Alignment.Center, - ) { - LumaIconView( - icon = LumaIcon.Search, - contentDescription = null, - tint = active, - modifier = Modifier.size(27.dp), - ) - } - } - if (searchSelected) { - Box( - Modifier - .size(56.dp) - .clip(CircleShape) - .then( - if (safeBackdrop != null) { - Modifier.drawBackdrop( - backdrop = rememberCombinedBackdrop(safeBackdrop, searchContentBackdrop), - shape = { Capsule() }, - effects = { - lens( - 10f.dp.toPx() * searchPressProgress, - 14f.dp.toPx() * searchPressProgress, - chromaticAberration = true, - ) - }, - highlight = { Highlight.Default.copy(alpha = searchPressProgress) }, - shadow = { Shadow(alpha = searchPressProgress) }, - innerShadow = { - InnerShadow( - radius = 8.dp * searchPressProgress, - alpha = searchPressProgress, - ) - }, - layerBlock = { - val selectedScale = 1f + (78f / 56f - 1f) * searchPressProgress - scaleX = selectedScale - scaleY = selectedScale - }, - onDrawSurface = { - drawRect( - if (LumaColors.isDark) Color.White.copy(alpha = 0.10f) - else Color.Black.copy(alpha = 0.10f), - alpha = 1f - searchPressProgress, - ) - drawRect(Color.Black.copy(alpha = 0.03f * searchPressProgress)) - }, - ) - } else { - Modifier.lumaFallbackGlass( - shape = CircleShape, - selected = true, - neutralVeil = if (LumaColors.isDark) { - Color(0xFF26313E).copy(alpha = 0.62f) - } else { - Color(0xFFC9D8E8).copy(alpha = 0.60f) - }, - ) - }, - ) + LiquidBottomTab(onClick = { onSelect(4) }) { + LumaAnimatedIcon( + icon = LumaIcon.Search, + selected = selectedTab == 4, + contentDescription = stringResource(com.example.myapplication.R.string.search), + tint = if (selectedTab == 4) active else inactive, + modifier = Modifier.size(27.dp), ) } - LumaIconView( - icon = LumaIcon.Search, - contentDescription = stringResource(com.example.myapplication.R.string.search), - tint = if (searchSelected) active else inactive, - modifier = Modifier.size(27.dp), - ) } } } diff --git a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt index 9a38a7c..e0b3494 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/LiquidGlass.kt @@ -101,6 +101,7 @@ import com.example.myapplication.ui.liquidglass.DampedDragAnimation import com.example.myapplication.ui.design.LumaColors import com.example.myapplication.ui.design.LumaIcon import com.example.myapplication.ui.design.LumaIconView +import com.example.myapplication.ui.design.LumaEmptyState import com.example.myapplication.ui.design.LumaGlassLevel import com.example.myapplication.ui.design.LumaMotion import com.example.myapplication.ui.design.LumaShapes @@ -123,12 +124,41 @@ internal val mainTabs = listOf( val LocalGlassBackdrop = staticCompositionLocalOf { null } +object AppBottomBarMetrics { + val contentInset = 90.dp +} + @Composable -fun bottomBarContentPadding(extra: Dp = 86.dp): PaddingValues { +fun bottomBarContentPadding(extra: Dp = AppBottomBarMetrics.contentInset): PaddingValues { val systemInset = WindowInsets.navigationBars.asPaddingValues().calculateBottomPadding() return PaddingValues(bottom = systemInset + extra) } +@Composable +fun LumaEmptyStateContainer( + icon: LumaIcon, + title: String, + description: String, + modifier: Modifier = Modifier, + showIconBackground: Boolean = false, + verticalOffset: Dp = 0.dp, +) { + Box( + modifier + .fillMaxSize() + .padding(bottom = bottomBarContentPadding().calculateBottomPadding()) + .offset(y = verticalOffset), + contentAlignment = Alignment.Center, + ) { + LumaEmptyState( + icon = icon, + title = title, + description = description, + showIconBackground = showIconBackground, + ) + } +} + object GlassTokens { val isDark get() = AppTheme.isDark val text: Color get() = LumaColors.text @@ -748,6 +778,8 @@ fun GlassSheet( onDismissRequest: () -> Unit, backdrop: Backdrop? = null, modifier: Modifier = Modifier, + surfaceOverlay: Color? = null, + horizontalMargin: Dp = 0.dp, content: @Composable ColumnScope.() -> Unit, ) { Dialog( @@ -769,6 +801,7 @@ fun GlassSheet( ) { Column( modifier + .padding(horizontal = horizontalMargin) .fillMaxWidth() .widthIn(max = 520.dp) .lumaGlass( @@ -778,6 +811,11 @@ fun GlassSheet( borderColor = GlassTokens.glassBorder, shadowElevation = 12.dp, ) + .then( + surfaceOverlay?.let { + Modifier.background(it, RoundedCornerShape(28.dp)) + } ?: Modifier, + ) .padding(20.dp), content = content, ) @@ -831,6 +869,7 @@ fun GlassInputField( focusRequester: FocusRequester? = null, containerTint: Color = GlassTokens.glassFillSoft, useBackdropGlass: Boolean = true, + fieldHeight: Dp = 48.dp, ) { val backdrop = LocalGlassBackdrop.current Column(modifier, verticalArrangement = Arrangement.spacedBy(4.dp)) { @@ -838,7 +877,7 @@ fun GlassInputField( val shape = RoundedCornerShape(12.dp) Box( Modifier.fillMaxWidth() - .heightIn(min = 48.dp) + .heightIn(min = fieldHeight) .then( if (useBackdropGlass) { Modifier.lumaGlass( @@ -854,6 +893,7 @@ fun GlassInputField( ) .border(0.7.dp, if (isError) GlassTokens.danger else GlassTokens.glassBorder, shape) .padding(horizontal = 14.dp, vertical = 12.dp), + contentAlignment = Alignment.Center, ) { BasicTextField( value = value, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt index 5007e98..582216f 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTab.kt @@ -16,8 +16,8 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.unit.dp import com.kyant.shapes.Capsule -internal val LocalLiquidBottomTabScale = - staticCompositionLocalOf { { 1f } } +internal val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false } +internal val LocalLiquidBottomTabScale = staticCompositionLocalOf { { 1f } } @Composable fun RowScope.LiquidBottomTab( @@ -44,9 +44,9 @@ fun RowScope.LiquidBottomTab( .fillMaxHeight() .weight(1f) .graphicsLayer { - val scale = scale() - scaleX = scale - scaleY = scale + val currentScale = scale() + scaleX = currentScale + scaleY = currentScale }, verticalArrangement = Arrangement.spacedBy(2f.dp, Alignment.CenterVertically), horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt index fb8251f..23c0478 100644 --- a/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt +++ b/app/src/main/kotlin/com/example/myapplication/ui/liquidglass/LiquidBottomTabs.kt @@ -23,19 +23,18 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow -import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastCoerceIn import androidx.compose.ui.util.fastRoundToInt @@ -64,9 +63,6 @@ import kotlinx.coroutines.launch import kotlin.math.abs import kotlin.math.sign -/** true = the hidden blue content-capture layer, false = the visible layer. */ -val LocalLiquidBottomTabsGhostLayer = staticCompositionLocalOf { false } - @Composable fun LiquidBottomTabs( selectedTabIndex: () -> Int, @@ -75,14 +71,18 @@ fun LiquidBottomTabs( tabsCount: Int, showSelection: () -> Boolean = { true }, modifier: Modifier = Modifier, + height: Dp = 64.dp, + surfaceAlpha: Float = 0.40f, + pressHighlight: Boolean = true, + ghostContent: (@Composable RowScope.() -> Unit)? = null, content: @Composable RowScope.() -> Unit, ) { val isLightTheme = !LumaColors.isDark - val accentColor = if (isLightTheme) Color(0xFF0088FF) else Color(0xFF0091FF) + val pressHighlightAlpha = if (pressHighlight) 1f else 0f val containerColor = if (isLightTheme) { - Color(0xFFFAFAFA).copy(alpha = 0.40f) + Color(0xFFFAFAFA).copy(alpha = surfaceAlpha) } else { - Color(0xFF121212).copy(alpha = 0.40f) + Color(0xFF121212).copy(alpha = surfaceAlpha) } val liquidFallback = AppTheme.glassMode == GlassMode.Liquid val fallbackContainerVeil = if (isLightTheme) { @@ -99,7 +99,7 @@ fun LiquidBottomTabs( } val nativeBackdrop = backdrop?.takeIf { LumaGlassRuntime.useNativeBackdrop } val selectionVisible = showSelection() - val tabsBackdrop = rememberLayerBackdrop() + val tabsBackdrop = if (nativeBackdrop != null) rememberLayerBackdrop() else null val currentOnTabSelected by rememberUpdatedState(onTabSelected) BoxWithConstraints(modifier, contentAlignment = Alignment.CenterStart) { @@ -167,7 +167,7 @@ fun LiquidBottomTabs( } } - val interactiveHighlight = remember(animationScope, isLtr, tabWidth) { + val interactiveHighlight = remember(animationScope) { InteractiveHighlight( animationScope = animationScope, position = { size, _ -> @@ -184,7 +184,7 @@ fun LiquidBottomTabs( Box( Modifier .graphicsLayer { translationX = panelOffset } - .height(64.dp) + .height(height) .fillMaxWidth() .lumaFallbackGlass( shape = Capsule(), @@ -200,7 +200,7 @@ fun LiquidBottomTabs( if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset } - .height(56.dp) + .height(height - 8.dp) .width(with(density) { tabWidth.toDp() }) .lumaFallbackGlass( shape = Capsule(), @@ -212,11 +212,11 @@ fun LiquidBottomTabs( Row( Modifier .graphicsLayer { translationX = panelOffset } - .then(interactiveHighlight.modifier) + .then(if (pressHighlight) interactiveHighlight.modifier else Modifier) .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) .clip(Capsule()) - .height(64.dp) + .height(height) .fillMaxWidth() .padding(LumaSpacing.xxs), verticalAlignment = Alignment.CenterVertically, @@ -242,11 +242,11 @@ fun LiquidBottomTabs( }, onDrawSurface = { drawRect(containerColor) }, ) - .then(interactiveHighlight.modifier) + .then(if (pressHighlight) interactiveHighlight.modifier else Modifier) + .border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule()) + .height(height) .then(interactiveHighlight.gestureModifier) .then(dampedDragAnimation.modifier) - .border(0.6.dp, LumaColors.glassBorder.copy(alpha = 0.72f), Capsule()) - .height(64.dp) .fillMaxWidth() .padding(4.dp), verticalAlignment = Alignment.CenterVertically, @@ -256,12 +256,17 @@ fun LiquidBottomTabs( } } - CompositionLocalProvider(LocalLiquidBottomTabsGhostLayer provides true) { + CompositionLocalProvider( + LocalLiquidBottomTabsGhostLayer provides true, + LocalLiquidBottomTabScale provides { + lerp(1f, 1.2f, dampedDragAnimation.pressProgress) + }, + ) { Row( Modifier .clearAndSetSemantics {} .alpha(0f) - .layerBackdrop(tabsBackdrop) + .layerBackdrop(tabsBackdrop!!) .graphicsLayer { translationX = panelOffset } .drawBackdrop( backdrop = nativeBackdrop, @@ -273,17 +278,16 @@ fun LiquidBottomTabs( lens(24f.dp.toPx() * progress, 24f.dp.toPx() * progress) }, highlight = { - Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress) + Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress * pressHighlightAlpha) }, onDrawSurface = { drawRect(containerColor) }, ) - .then(interactiveHighlight.modifier) - .height(56.dp) - .fillMaxWidth() - .padding(horizontal = 4.dp) - .graphicsLayer(colorFilter = ColorFilter.tint(accentColor)), + .then(if (pressHighlight) interactiveHighlight.modifier else Modifier) + .height(height - 8.dp) + .fillMaxWidth() + .padding(horizontal = 4.dp), verticalAlignment = Alignment.CenterVertically, - content = content, + content = ghostContent ?: content, ) } @@ -296,8 +300,10 @@ fun LiquidBottomTabs( if (isLtr) dampedDragAnimation.value * tabWidth + panelOffset else size.width - (dampedDragAnimation.value + 1f) * tabWidth + panelOffset } + .then(interactiveHighlight.gestureModifier) + .then(dampedDragAnimation.modifier) .drawBackdrop( - backdrop = rememberCombinedBackdrop(nativeBackdrop, tabsBackdrop), + backdrop = rememberCombinedBackdrop(nativeBackdrop, tabsBackdrop!!), shape = { Capsule() }, effects = { val progress = dampedDragAnimation.pressProgress @@ -308,7 +314,7 @@ fun LiquidBottomTabs( ) }, highlight = { - Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress) + Highlight.Default.copy(alpha = dampedDragAnimation.pressProgress * pressHighlightAlpha) }, shadow = { Shadow(alpha = dampedDragAnimation.pressProgress) }, innerShadow = { @@ -332,7 +338,7 @@ fun LiquidBottomTabs( drawRect(Color.Black.copy(alpha = 0.03f * progress)) }, ) - .height(56.dp) + .height(height - 8.dp) .fillMaxWidth(1f / tabsCount), ) }