chat android tv adaptation

This commit is contained in:
2026-08-09 11:59:33 +03:00
parent db47d8c2ce
commit 523b164964
8 changed files with 302 additions and 164 deletions
@@ -5,15 +5,8 @@ import androiddev.apps.vbox.client.shared.generated.resources.not_found
import androiddev.apps.vbox.client.shared.generated.resources.recommendations import androiddev.apps.vbox.client.shared.generated.resources.recommendations
import androiddev.apps.vbox.client.shared.generated.resources.retry import androiddev.apps.vbox.client.shared.generated.resources.retry
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.Image import androidx.compose.foundation.Image
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -24,7 +17,6 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.itemsIndexed import androidx.compose.foundation.lazy.grid.itemsIndexed
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text import androidx.compose.material3.Text
@@ -32,17 +24,13 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
@@ -54,6 +42,7 @@ import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.ui.components.ErrorContent import ru.shadowsparky.ui.components.ErrorContent
import ru.shadowsparky.ui.components.LazyVerticalGrid import ru.shadowsparky.ui.components.LazyVerticalGrid
import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.theme.focusBorder
import ru.shadowsparky.vbox.shared.domain.model.VideoItem import ru.shadowsparky.vbox.shared.domain.model.VideoItem
import ru.shadowsparky.vbox.shared.presentation.AppScaffold import ru.shadowsparky.vbox.shared.presentation.AppScaffold
import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch
@@ -179,41 +168,11 @@ private fun Poster(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
var isFocused by remember { mutableStateOf(false) }
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.0f else 0.92f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "scale"
)
val borderColor by animateColorAsState(
targetValue = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent,
animationSpec = tween(200),
label = "borderColor"
)
val borderWidth by animateDpAsState(
targetValue = if (isFocused) 3.dp else 0.dp,
animationSpec = tween(200),
label = "borderWidth"
)
Box( Box(
modifier = modifier modifier = modifier
.aspectRatio(0.7f) .aspectRatio(0.7f)
.graphicsLayer { .focusBorder()
scaleX = scale
scaleY = scale
clip = true
shape = RoundedCornerShape(12.dp)
}
.onFocusChanged { isFocused = it.isFocused }
.border(
width = borderWidth,
color = borderColor,
shape = RoundedCornerShape(12.dp)
)
.clickable(onClick = onClick) .clickable(onClick = onClick)
) { ) {
Image( Image(
@@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.window.ComposeViewport import androidx.compose.ui.window.ComposeViewport
@@ -62,24 +61,19 @@ fun mainInternal() {
startKoin<VBox>() startKoin<VBox>()
val lifecycle = LifecycleRegistry() val lifecycle = LifecycleRegistry()
val composeRoot = document.getElementById("compose-root") as HTMLElement val composeRoot = document.getElementById("compose-root") as HTMLElement
var rootRef: VBoxRootComponent? = null
val root: VBoxRootComponent = withWebHistory { stateKeeper: StateKeeper, deepLinkUrl: String? -> val root: VBoxRootComponent = withWebHistory { stateKeeper: StateKeeper, deepLinkUrl: String? ->
if (rootRef == null) { createRoot(lifecycle, stateKeeper, deepLinkUrl)
rootRef = createRoot(lifecycle, stateKeeper, deepLinkUrl)
}
rootRef
} }
hookPageVisibility(lifecycle) hookPageVisibility(lifecycle)
lifecycle.doOnResume { hideElementById("loader") } lifecycle.doOnResume { hideElementById("loader") }
ComposeViewport(viewportContainer = composeRoot, content = { ComposeViewport(composeRoot) {
val r = remember { root }
LaunchedEffect(null) { LaunchedEffect(null) {
r.onFinish.collect { r.navReplace(Route.Loading()) } root.onFinish.collect { root.navReplace(Route.Loading()) }
} }
Box(Modifier.fillMaxSize().padding(WindowInsets.safeDrawing.asPaddingValues())) { Box(Modifier.fillMaxSize().padding(WindowInsets.safeDrawing.asPaddingValues())) {
Nav(r) Nav(root)
} }
}) }
} }
private fun createRoot( private fun createRoot(
@@ -87,9 +81,7 @@ private fun createRoot(
stateKeeper: StateKeeper, stateKeeper: StateKeeper,
deepLinkUrl: String? deepLinkUrl: String?
): VBoxRootComponent { ): VBoxRootComponent {
val ctx = DefaultComponentContext( val ctx = DefaultComponentContext(lifecycle, stateKeeper)
lifecycle = lifecycle, stateKeeper = stateKeeper
)
val deep = deepLinkUrl?.let { UrlRoutes.parseUrl(it) } val deep = deepLinkUrl?.let { UrlRoutes.parseUrl(it) }
val initRoute: Route = deep ?: Route.default val initRoute: Route = deep ?: Route.default
return koin.get<RootFactory>().create(context = ctx, initStack = initRoute) return koin.get<RootFactory>().create(context = ctx, initStack = initRoute)
@@ -7,6 +7,7 @@ import androiddev.feature.chat.chat_client.generated.resources.chat_ok
import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button import androiddev.feature.chat.chat_client.generated.resources.chat_retry_button
import androiddev.feature.chat.chat_client.generated.resources.send import androiddev.feature.chat.chat_client.generated.resources.send
import androidx.compose.foundation.focusable import androidx.compose.foundation.focusable
import androidx.compose.foundation.gestures.animateScrollBy
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -20,12 +21,14 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button import androidx.compose.material3.Button
import androidx.compose.material3.Card import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import androidx.compose.material3.CardDefaults
@@ -37,20 +40,25 @@ import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.withFrameNanos import androidx.compose.runtime.withFrameNanos
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.key.Key import androidx.compose.ui.input.key.Key
import androidx.compose.ui.input.key.KeyEvent
import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.KeyEventType
import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.key
import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.onKeyEvent
import androidx.compose.ui.input.key.type import androidx.compose.ui.input.key.type
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.LinkAnnotation
import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.TextLinkStyles import androidx.compose.ui.text.TextLinkStyles
@@ -61,6 +69,7 @@ import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.withLink import androidx.compose.ui.text.withLink
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import ru.shadowsparky.chat.domain.ChatRoles import ru.shadowsparky.chat.domain.ChatRoles
@@ -69,6 +78,9 @@ import ru.shadowsparky.chat.domain.TextToken
import ru.shadowsparky.ui.components.AlertContent import ru.shadowsparky.ui.components.AlertContent
import ru.shadowsparky.ui.components.LazyColumn import ru.shadowsparky.ui.components.LazyColumn
import ru.shadowsparky.ui.components.Loading import ru.shadowsparky.ui.components.Loading
import ru.shadowsparky.ui.components.TextPreference
import ru.shadowsparky.ui.components.isTv
import ru.shadowsparky.ui.theme.focusBorder
@Composable @Composable
fun ChatContent( fun ChatContent(
@@ -132,7 +144,6 @@ private fun ChatContent(
val listState = rememberLazyListState() val listState = rememberLazyListState()
var textInput by remember { mutableStateOf("") } var textInput by remember { mutableStateOf("") }
val textFieldFocusRequester = remember { FocusRequester() } val textFieldFocusRequester = remember { FocusRequester() }
val listFocusRequester = remember { FocusRequester() }
LaunchedEffect(state.messages.size) { LaunchedEffect(state.messages.size) {
if (state.messages.isNotEmpty()) { if (state.messages.isNotEmpty()) {
@@ -145,18 +156,25 @@ private fun ChatContent(
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
LazyColumn( LazyColumn(
state = listState, state = listState,
modifier = Modifier.focusRequester(listFocusRequester) modifier = Modifier.weight(1f)
.weight(1f)
.fillMaxWidth(), .fillMaxWidth(),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
reverseLayout = true reverseLayout = true
) { ) {
items(items = state.messages, key = { it.id ?: it.hashCode() }) { message -> itemsIndexed(
items = state.messages,
key = { _, item -> item.id ?: item.hashCode() }
) { index, message ->
MessageBubble( MessageBubble(
message = message, message = message,
onParseMessage = onParseMessage, onParseMessage = onParseMessage,
onClickLink = onClickLink onClickLink = onClickLink,
onKeyEvent = {
if (index == 0) {
if (it.key == Key.DirectionDown) textFieldFocusRequester.requestFocus()
}
}
) )
} }
item { Spacer(Modifier.height(4.dp)) } item { Spacer(Modifier.height(4.dp)) }
@@ -178,7 +196,7 @@ private fun ChatContent(
OutlinedTextField( OutlinedTextField(
value = textInput, value = textInput,
onValueChange = { textInput = it }, onValueChange = { textInput = it },
modifier = Modifier.focusRequester(textFieldFocusRequester).resetFocusOnUpEvent(listFocusRequester).weight(1f), modifier = Modifier.focusRequester(textFieldFocusRequester).weight(1f),
shape = RoundedCornerShape(24.dp), shape = RoundedCornerShape(24.dp),
placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) }, placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) },
enabled = !state.isSending, enabled = !state.isSending,
@@ -207,73 +225,191 @@ private fun ChatContent(
private fun MessageBubble( private fun MessageBubble(
message: Message, message: Message,
onParseMessage: (String) -> List<TextToken>, onParseMessage: (String) -> List<TextToken>,
onClickLink: (String) -> Unit onClickLink: (String) -> Unit,
onKeyEvent: (KeyEvent) -> Unit
) { ) {
val isUser = message.role == ChatRoles.USER val isUser = message.role == ChatRoles.USER
val alignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
val containerColor = if (isUser) MaterialTheme.colorScheme.primaryContainer else MaterialTheme.colorScheme.surfaceVariant
val contentColor = if (isUser) MaterialTheme.colorScheme.onPrimaryContainer else MaterialTheme.colorScheme.onSurfaceVariant
val tokens = remember(message.content) { onParseMessage(message.content) } val tokens = remember(message.content) { onParseMessage(message.content) }
val linkTokens by derivedStateOf { tokens.filterIsInstance<TextToken.Link>() }
val primaryColor = MaterialTheme.colorScheme.primary val primaryColor = MaterialTheme.colorScheme.primary
val focusBackgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) val focusedColor = MaterialTheme.colorScheme.error
val annotatedString = remember(tokens, primaryColor, focusBackgroundColor) { val isTv = isTv()
buildAnnotatedString { val annotatedString = remember(tokens) {
tokens.forEach { token -> if (isTv) {
when (token) { buildAnnotatedString { append(message.content) }
is TextToken.Plain -> append(token.text) } else {
is TextToken.Link -> { buildText(tokens, primaryColor, focusedColor, onClickLink)
val linkAnnotation = LinkAnnotation.Clickable( }
tag = "LINK", }
styles = TextLinkStyles( var dialog by remember { mutableStateOf(false) }
style = SpanStyle( var boxModifier = Modifier.widthIn(max = 280.dp)
color = primaryColor, if (isTv) {
fontWeight = FontWeight.Bold, boxModifier = boxModifier.focusBorder(false).onKeyEvent {
textDecoration = TextDecoration.Underline if (it.type == KeyEventType.KeyDown) {
), when (it.key) {
focusedStyle = SpanStyle( Key.Enter -> dialog = true
background = focusBackgroundColor, else -> onKeyEvent(it)
color = primaryColor
)
),
linkInteractionListener = { onClickLink(token.text) }
)
withLink(linkAnnotation) { append(token.text) }
}
} }
} }
false
} }
} }
Box( Box(
modifier = Modifier.fillMaxWidth().focusable(), modifier = Modifier.fillMaxWidth(),
contentAlignment = alignment contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart
) { ) {
Card( Card(
colors = CardDefaults.cardColors( colors = CardDefaults.cardColors(
containerColor = containerColor, containerColor = if (isUser) {
contentColor = contentColor MaterialTheme.colorScheme.primaryContainer
} else {
MaterialTheme.colorScheme.surfaceVariant
},
contentColor = if (isUser) {
MaterialTheme.colorScheme.onPrimaryContainer
} else {
MaterialTheme.colorScheme.onSurfaceVariant
}
), ),
modifier = Modifier.widthIn(max = 280.dp) modifier = boxModifier
) { ) { ChatText(annotatedString) }
Column(modifier = Modifier.padding(12.dp)) { }
SelectionContainer { if (dialog && isTv) {
Text( TextDialog(message.content, linkTokens, onClickLink) { dialog = false }
text = annotatedString, }
style = MaterialTheme.typography.bodyLarge }
)
@Composable
private fun ChatText(annotatedString: AnnotatedString) {
SelectionContainer {
Column(modifier = Modifier.padding(8.dp)) {
Text(
text = annotatedString,
style = MaterialTheme.typography.bodyLarge
)
}
}
}
@Composable
private fun TextDialog(
text: String,
links: List<TextToken.Link>,
onClickLink: (String) -> Unit,
dismiss: () -> Unit
) {
var showLinks by remember { mutableStateOf(false) }
AlertContent(
onDismissRequest = dismiss,
content = {
Column {
val focusRequester = remember { FocusRequester() }
if (!showLinks) {
ScrollableMessage(focusRequester, text) {
if (links.isNotEmpty()) {
showLinks = true
} else {
dismiss()
}
}
} else {
LazyColumn {
itemsIndexed(links) { index, item ->
val mod = Modifier.focusBorder(false)
TextPreference(
text = item.text,
onClick = { onClickLink(item.text) },
modifier = if (index == 0) {
mod.focusRequester(focusRequester)
} else {
mod
}
)
}
}
}
LaunchedEffect(showLinks) {
withFrameNanos {}
focusRequester.requestFocus()
} }
} }
} }
)
}
@Composable
private fun ScrollableMessage(
focusRequester: FocusRequester,
text: String,
onEnter: () -> Unit
) {
val scrollStep = 250f
val scrollState = rememberScrollState()
val coroutineScope = rememberCoroutineScope()
Box(
modifier = Modifier.focusRequester(focusRequester)
.focusable()
.onKeyEvent { keyEvent ->
if (keyEvent.type == KeyEventType.KeyDown) {
when (keyEvent.key) {
Key.DirectionDown -> {
coroutineScope.launch { scrollState.animateScrollBy(scrollStep) }
true
}
Key.DirectionUp -> {
coroutineScope.launch { scrollState.animateScrollBy(-scrollStep) }
true
}
Key.Enter -> {
onEnter()
true
}
else -> false
}
} else {
false
}
}
) {
Text(
text = text,
modifier = Modifier.fillMaxWidth()
.verticalScroll(scrollState)
)
} }
} }
private fun Modifier.resetFocusOnUpEvent(focusRequester: FocusRequester): Modifier { private fun buildText(
return onKeyEvent { tokens: List<TextToken>,
if (it.type == KeyEventType.KeyUp) { primaryColor: Color,
if (it.key == Key.DirectionUp) { focusedColor: Color,
focusRequester.requestFocus() onClickLink: (String) -> Unit
): AnnotatedString = buildAnnotatedString {
tokens.forEach { token ->
when (token) {
is TextToken.Plain -> append(token.text)
is TextToken.Link -> {
val linkAnnotation = LinkAnnotation.Clickable(
tag = "LINK",
styles = TextLinkStyles(
style = SpanStyle(
color = primaryColor,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline
),
focusedStyle = SpanStyle(
color = focusedColor,
fontWeight = FontWeight.Bold,
textDecoration = TextDecoration.Underline
)
),
linkInteractionListener = { onClickLink(token.text) }
)
withLink(linkAnnotation) { append(token.text) }
} }
} }
true
} }
} }
@@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ColorScheme import androidx.compose.material3.ColorScheme
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -18,6 +19,7 @@ import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBar import androidx.compose.material3.NavigationBar
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRail
import androidx.compose.material3.NavigationRailItem import androidx.compose.material3.NavigationRailItem
@@ -30,6 +32,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.painter.Painter import androidx.compose.ui.graphics.painter.Painter
@@ -37,7 +40,6 @@ import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.shadowsparky.ui.nav.Route import ru.shadowsparky.ui.nav.Route
import ru.shadowsparky.ui.theme.ComponentDefaults
import ru.shadowsparky.ui.theme.getColorScheme import ru.shadowsparky.ui.theme.getColorScheme
@Immutable @Immutable
@@ -138,8 +140,8 @@ private fun <T : Route> BottomBar(
.windowInsetsPadding(WindowInsets.navigationBars) .windowInsetsPadding(WindowInsets.navigationBars)
) { ) {
NavigationBar( NavigationBar(
containerColor = ComponentDefaults.navBarContentColor(), containerColor = navBarContentColor(),
modifier = ComponentDefaults.roundedClip().blurEffect() modifier = Modifier.clip(RoundedCornerShape(24.dp)).blurEffect()
) { ) {
entries.forEach { entry -> entries.forEach { entry ->
NavigationBarItem( NavigationBarItem(
@@ -153,6 +155,14 @@ private fun <T : Route> BottomBar(
} }
} }
@Composable
private fun navBarContentColor(): Color {
return if (isBlurEnabled()) {
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
} else {
NavigationBarDefaults.containerColor
}
}
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -11,10 +11,11 @@ import androidx.compose.material3.SearchBarState
import androidx.compose.material3.rememberSearchBarState import androidx.compose.material3.rememberSearchBarState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import ru.shadowsparky.ui.blurEffect import ru.shadowsparky.ui.blurEffect
import ru.shadowsparky.ui.theme.ComponentDefaults import ru.shadowsparky.ui.isBlurEnabled
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -25,7 +26,7 @@ fun AppBarWithSearch(
navigationIcon: @Composable (() -> Unit)? = null, navigationIcon: @Composable (() -> Unit)? = null,
actions: @Composable (RowScope.() -> Unit)? = null, actions: @Composable (RowScope.() -> Unit)? = null,
shape: Shape = SearchBarDefaults.inputFieldShape, shape: Shape = SearchBarDefaults.inputFieldShape,
colors: AppBarWithSearchColors = ComponentDefaults.appBarWithSearchDefaults(), colors: AppBarWithSearchColors = appBarWithSearchDefaults(),
tonalElevation: Dp = SearchBarDefaults.TonalElevation, tonalElevation: Dp = SearchBarDefaults.TonalElevation,
shadowElevation: Dp = SearchBarDefaults.ShadowElevation, shadowElevation: Dp = SearchBarDefaults.ShadowElevation,
contentPadding: PaddingValues = SearchBarDefaults.AppBarContentPadding, contentPadding: PaddingValues = SearchBarDefaults.AppBarContentPadding,
@@ -47,3 +48,15 @@ fun AppBarWithSearch(
scrollBehavior scrollBehavior
) )
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun appBarWithSearchDefaults(): AppBarWithSearchColors {
return if (isBlurEnabled()) {
SearchBarDefaults.appBarWithSearchColors(
appBarContainerColor = Color.Transparent
)
} else {
SearchBarDefaults.appBarWithSearchColors()
}
}
@@ -10,9 +10,10 @@ import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import ru.shadowsparky.ui.blurEffect import ru.shadowsparky.ui.blurEffect
import ru.shadowsparky.ui.theme.ComponentDefaults import ru.shadowsparky.ui.isBlurEnabled
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@@ -23,7 +24,7 @@ fun TopAppBar(
actions: @Composable RowScope.() -> Unit = {}, actions: @Composable RowScope.() -> Unit = {},
expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight,
windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, windowInsets: WindowInsets = TopAppBarDefaults.windowInsets,
colors: TopAppBarColors = ComponentDefaults.topAppBarColors(), colors: TopAppBarColors = topAppBarColors(),
scrollBehavior: TopAppBarScrollBehavior? = null, scrollBehavior: TopAppBarScrollBehavior? = null,
contentPadding: PaddingValues = TopAppBarDefaults.ContentPadding contentPadding: PaddingValues = TopAppBarDefaults.ContentPadding
) { ) {
@@ -39,3 +40,16 @@ fun TopAppBar(
contentPadding = contentPadding contentPadding = contentPadding
) )
} }
@Composable
private fun topAppBarColors(): TopAppBarColors {
return if (isBlurEnabled()) {
TopAppBarDefaults.topAppBarColors(
Color.Transparent,
Color.Transparent
)
} else {
TopAppBarDefaults.topAppBarColors()
}
}
@@ -1,56 +1,68 @@
package ru.shadowsparky.ui.theme package ru.shadowsparky.ui.theme
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.foundation.border
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.AppBarWithSearchColors
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.NavigationBarDefaults
import androidx.compose.material3.SearchBarDefaults
import androidx.compose.material3.TopAppBarColors
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import ru.shadowsparky.ui.isBlurEnabled
object ComponentDefaults { object ComponentDefaults {
@Composable @Composable
fun topAppBarColors(): TopAppBarColors { internal fun focusBorder(
return if (isBlurEnabled()) { modifier: Modifier,
TopAppBarDefaults.topAppBarColors( needScale: Boolean
Color.Transparent, ): Modifier {
Color.Transparent var isFocused by remember { mutableStateOf(false) }
val borderColor by animateColorAsState(
targetValue = if (isFocused) MaterialTheme.colorScheme.primary else Color.Transparent,
animationSpec = tween(200),
label = "borderColor"
)
val borderWidth by animateDpAsState(
targetValue = if (isFocused) 3.dp else 0.dp,
animationSpec = tween(200),
label = "borderWidth"
)
val scale by animateFloatAsState(
targetValue = if (isFocused) 1.0f else 0.92f,
animationSpec = spring(stiffness = Spring.StiffnessMediumLow),
label = "scale"
)
val mod = if (needScale) {
modifier.graphicsLayer {
scaleX = scale
scaleY = scale
clip = true
shape = RoundedCornerShape(12.dp)
}
} else {
modifier
}
return mod.onFocusChanged { isFocused = it.isFocused }
.border(
width = borderWidth,
color = borderColor,
shape = RoundedCornerShape(12.dp)
) )
} else {
TopAppBarDefaults.topAppBarColors()
}
}
@Composable
fun navBarContentColor(): Color {
return if (isBlurEnabled()) {
MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f)
} else {
NavigationBarDefaults.containerColor
}
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun appBarWithSearchDefaults(): AppBarWithSearchColors {
return if (isBlurEnabled()) {
SearchBarDefaults.appBarWithSearchColors(
appBarContainerColor = Color.Transparent
)
} else {
SearchBarDefaults.appBarWithSearchColors()
}
}
fun roundedClip(modifier: Modifier = Modifier): Modifier {
return modifier.clip(RoundedCornerShape(24.dp))
} }
} }
@Composable
fun Modifier.focusBorder(needScale: Boolean = true): Modifier {
return ComponentDefaults.focusBorder(this, needScale)
}
@@ -38,10 +38,10 @@ fun hookPageVisibility(lifecycle: LifecycleRegistry) {
fun onPageHideShow(hidden: Boolean) { fun onPageHideShow(hidden: Boolean) {
if (hidden) { if (hidden) {
// уходим глубже: STARTED → CREATED
when (lifecycle.state) { when (lifecycle.state) {
Lifecycle.State.RESUMED -> { Lifecycle.State.RESUMED -> {
lifecycle.pause(); lifecycle.stop() lifecycle.pause();
lifecycle.stop()
} }
Lifecycle.State.STARTED -> { Lifecycle.State.STARTED -> {
@@ -51,14 +51,16 @@ fun hookPageVisibility(lifecycle: LifecycleRegistry) {
else -> Unit else -> Unit
} }
} else { } else {
// pageshow: поднимемся обратно
when (lifecycle.state) { when (lifecycle.state) {
Lifecycle.State.INITIALIZED -> { Lifecycle.State.INITIALIZED -> {
lifecycle.create(); lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() lifecycle.create()
lifecycle.start()
if (pageVisibilityState() == "visible") lifecycle.resume()
} }
Lifecycle.State.CREATED -> { Lifecycle.State.CREATED -> {
lifecycle.start(); if (pageVisibilityState() == "visible") lifecycle.resume() lifecycle.start()
if (pageVisibilityState() == "visible") lifecycle.resume()
} }
Lifecycle.State.STARTED -> { Lifecycle.State.STARTED -> {