diff --git a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt index 8fb3c3d..b1341d9 100644 --- a/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt +++ b/apps/vbox/client/shared/src/commonMain/kotlin/ru/shadowsparky/vbox/shared/presentation/list/VideoScreen.kt @@ -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.retry 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.background -import androidx.compose.foundation.border import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement 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.itemsIndexed import androidx.compose.foundation.lazy.grid.rememberLazyGridState -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -32,17 +24,13 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.Color -import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.text.font.FontWeight 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.LazyVerticalGrid 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.presentation.AppScaffold import ru.shadowsparky.vbox.shared.presentation.search.AppBarWithSearch @@ -179,41 +168,11 @@ private fun Poster( onClick: () -> Unit, 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( modifier = modifier .aspectRatio(0.7f) - .graphicsLayer { - scaleX = scale - scaleY = scale - clip = true - shape = RoundedCornerShape(12.dp) - } - .onFocusChanged { isFocused = it.isFocused } - .border( - width = borderWidth, - color = borderColor, - shape = RoundedCornerShape(12.dp) - ) + .focusBorder() .clickable(onClick = onClick) ) { Image( diff --git a/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt b/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt index 5ba8a3f..f3e4f65 100644 --- a/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt +++ b/apps/vbox/client/web/src/wasmJsMain/kotlin/ru/shadowsparky/vbox/Main.kt @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.safeDrawing import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.remember import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.window.ComposeViewport @@ -62,24 +61,19 @@ fun mainInternal() { startKoin() val lifecycle = LifecycleRegistry() val composeRoot = document.getElementById("compose-root") as HTMLElement - var rootRef: VBoxRootComponent? = null val root: VBoxRootComponent = withWebHistory { stateKeeper: StateKeeper, deepLinkUrl: String? -> - if (rootRef == null) { - rootRef = createRoot(lifecycle, stateKeeper, deepLinkUrl) - } - rootRef + createRoot(lifecycle, stateKeeper, deepLinkUrl) } hookPageVisibility(lifecycle) lifecycle.doOnResume { hideElementById("loader") } - ComposeViewport(viewportContainer = composeRoot, content = { - val r = remember { root } + ComposeViewport(composeRoot) { LaunchedEffect(null) { - r.onFinish.collect { r.navReplace(Route.Loading()) } + root.onFinish.collect { root.navReplace(Route.Loading()) } } Box(Modifier.fillMaxSize().padding(WindowInsets.safeDrawing.asPaddingValues())) { - Nav(r) + Nav(root) } - }) + } } private fun createRoot( @@ -87,9 +81,7 @@ private fun createRoot( stateKeeper: StateKeeper, deepLinkUrl: String? ): VBoxRootComponent { - val ctx = DefaultComponentContext( - lifecycle = lifecycle, stateKeeper = stateKeeper - ) + val ctx = DefaultComponentContext(lifecycle, stateKeeper) val deep = deepLinkUrl?.let { UrlRoutes.parseUrl(it) } val initRoute: Route = deep ?: Route.default return koin.get().create(context = ctx, initStack = initRoute) diff --git a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt index a45b939..7ed5f23 100644 --- a/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt +++ b/feature/chat/chat-client/src/commonMain/kotlin/ru/shadowsparky/chat/presentation/ChatScreen.kt @@ -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.send import androidx.compose.foundation.focusable +import androidx.compose.foundation.gestures.animateScrollBy import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box 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.padding 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.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.selection.SelectionContainer +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.Card import androidx.compose.material3.CardDefaults @@ -37,20 +40,25 @@ import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.runtime.withFrameNanos import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier 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.KeyEvent import androidx.compose.ui.input.key.KeyEventType import androidx.compose.ui.input.key.key import androidx.compose.ui.input.key.onKeyEvent import androidx.compose.ui.input.key.type +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle 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.unit.LayoutDirection import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.stringResource 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.LazyColumn 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 fun ChatContent( @@ -132,7 +144,6 @@ private fun ChatContent( val listState = rememberLazyListState() var textInput by remember { mutableStateOf("") } val textFieldFocusRequester = remember { FocusRequester() } - val listFocusRequester = remember { FocusRequester() } LaunchedEffect(state.messages.size) { if (state.messages.isNotEmpty()) { @@ -145,18 +156,25 @@ private fun ChatContent( Column(modifier = Modifier.fillMaxSize()) { LazyColumn( state = listState, - modifier = Modifier.focusRequester(listFocusRequester) - .weight(1f) + modifier = Modifier.weight(1f) .fillMaxWidth(), contentPadding = contentPadding, verticalArrangement = Arrangement.spacedBy(8.dp), 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( message = message, onParseMessage = onParseMessage, - onClickLink = onClickLink + onClickLink = onClickLink, + onKeyEvent = { + if (index == 0) { + if (it.key == Key.DirectionDown) textFieldFocusRequester.requestFocus() + } + } ) } item { Spacer(Modifier.height(4.dp)) } @@ -178,7 +196,7 @@ private fun ChatContent( OutlinedTextField( value = textInput, onValueChange = { textInput = it }, - modifier = Modifier.focusRequester(textFieldFocusRequester).resetFocusOnUpEvent(listFocusRequester).weight(1f), + modifier = Modifier.focusRequester(textFieldFocusRequester).weight(1f), shape = RoundedCornerShape(24.dp), placeholder = { Text(stringResource(Res.string.chat_input_placeholder)) }, enabled = !state.isSending, @@ -207,73 +225,191 @@ private fun ChatContent( private fun MessageBubble( message: Message, onParseMessage: (String) -> List, - onClickLink: (String) -> Unit + onClickLink: (String) -> Unit, + onKeyEvent: (KeyEvent) -> Unit ) { 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 linkTokens by derivedStateOf { tokens.filterIsInstance() } val primaryColor = MaterialTheme.colorScheme.primary - val focusBackgroundColor = MaterialTheme.colorScheme.primary.copy(alpha = 0.15f) - val annotatedString = remember(tokens, primaryColor, focusBackgroundColor) { - 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( - background = focusBackgroundColor, - color = primaryColor - ) - ), - linkInteractionListener = { onClickLink(token.text) } - ) - withLink(linkAnnotation) { append(token.text) } - } + val focusedColor = MaterialTheme.colorScheme.error + val isTv = isTv() + val annotatedString = remember(tokens) { + if (isTv) { + buildAnnotatedString { append(message.content) } + } else { + buildText(tokens, primaryColor, focusedColor, onClickLink) + } + } + var dialog by remember { mutableStateOf(false) } + var boxModifier = Modifier.widthIn(max = 280.dp) + if (isTv) { + boxModifier = boxModifier.focusBorder(false).onKeyEvent { + if (it.type == KeyEventType.KeyDown) { + when (it.key) { + Key.Enter -> dialog = true + else -> onKeyEvent(it) } } + false } } Box( - modifier = Modifier.fillMaxWidth().focusable(), - contentAlignment = alignment + modifier = Modifier.fillMaxWidth(), + contentAlignment = if (isUser) Alignment.CenterEnd else Alignment.CenterStart ) { Card( colors = CardDefaults.cardColors( - containerColor = containerColor, - contentColor = contentColor + containerColor = if (isUser) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.surfaceVariant + }, + contentColor = if (isUser) { + MaterialTheme.colorScheme.onPrimaryContainer + } else { + MaterialTheme.colorScheme.onSurfaceVariant + } ), - modifier = Modifier.widthIn(max = 280.dp) - ) { - Column(modifier = Modifier.padding(12.dp)) { - SelectionContainer { - Text( - text = annotatedString, - style = MaterialTheme.typography.bodyLarge - ) + modifier = boxModifier + ) { ChatText(annotatedString) } + } + if (dialog && isTv) { + TextDialog(message.content, linkTokens, onClickLink) { dialog = false } + } +} + +@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, + 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 { - return onKeyEvent { - if (it.type == KeyEventType.KeyUp) { - if (it.key == Key.DirectionUp) { - focusRequester.requestFocus() +private fun buildText( + tokens: List, + primaryColor: Color, + focusedColor: Color, + 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 } } - diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt index 5a4fe09..0149b12 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/AppScaffold.kt @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBars import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.ColorScheme import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi @@ -18,6 +19,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBar +import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRail import androidx.compose.material3.NavigationRailItem @@ -30,6 +32,7 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Immutable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color 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.dp import ru.shadowsparky.ui.nav.Route -import ru.shadowsparky.ui.theme.ComponentDefaults import ru.shadowsparky.ui.theme.getColorScheme @Immutable @@ -138,8 +140,8 @@ private fun BottomBar( .windowInsetsPadding(WindowInsets.navigationBars) ) { NavigationBar( - containerColor = ComponentDefaults.navBarContentColor(), - modifier = ComponentDefaults.roundedClip().blurEffect() + containerColor = navBarContentColor(), + modifier = Modifier.clip(RoundedCornerShape(24.dp)).blurEffect() ) { entries.forEach { entry -> NavigationBarItem( @@ -153,6 +155,14 @@ private fun BottomBar( } } +@Composable +private fun navBarContentColor(): Color { + return if (isBlurEnabled()) { + MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.5f) + } else { + NavigationBarDefaults.containerColor + } +} @OptIn(ExperimentalMaterial3Api::class) @Composable diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/AppBarWithSearch.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/AppBarWithSearch.kt index def52c4..be8e8d6 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/AppBarWithSearch.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/AppBarWithSearch.kt @@ -11,10 +11,11 @@ import androidx.compose.material3.SearchBarState import androidx.compose.material3.rememberSearchBarState import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Shape import androidx.compose.ui.unit.Dp import ru.shadowsparky.ui.blurEffect -import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.ui.isBlurEnabled @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,7 +26,7 @@ fun AppBarWithSearch( navigationIcon: @Composable (() -> Unit)? = null, actions: @Composable (RowScope.() -> Unit)? = null, shape: Shape = SearchBarDefaults.inputFieldShape, - colors: AppBarWithSearchColors = ComponentDefaults.appBarWithSearchDefaults(), + colors: AppBarWithSearchColors = appBarWithSearchDefaults(), tonalElevation: Dp = SearchBarDefaults.TonalElevation, shadowElevation: Dp = SearchBarDefaults.ShadowElevation, contentPadding: PaddingValues = SearchBarDefaults.AppBarContentPadding, @@ -47,3 +48,15 @@ fun AppBarWithSearch( scrollBehavior ) } + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun appBarWithSearchDefaults(): AppBarWithSearchColors { + return if (isBlurEnabled()) { + SearchBarDefaults.appBarWithSearchColors( + appBarContainerColor = Color.Transparent + ) + } else { + SearchBarDefaults.appBarWithSearchColors() + } +} diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TopAppBar.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TopAppBar.kt index bdecf34..4452b61 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TopAppBar.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/components/TopAppBar.kt @@ -10,9 +10,10 @@ import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.TopAppBarScrollBehavior import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.unit.Dp import ru.shadowsparky.ui.blurEffect -import ru.shadowsparky.ui.theme.ComponentDefaults +import ru.shadowsparky.ui.isBlurEnabled @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -23,7 +24,7 @@ fun TopAppBar( actions: @Composable RowScope.() -> Unit = {}, expandedHeight: Dp = TopAppBarDefaults.TopAppBarExpandedHeight, windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, - colors: TopAppBarColors = ComponentDefaults.topAppBarColors(), + colors: TopAppBarColors = topAppBarColors(), scrollBehavior: TopAppBarScrollBehavior? = null, contentPadding: PaddingValues = TopAppBarDefaults.ContentPadding ) { @@ -39,3 +40,16 @@ fun TopAppBar( contentPadding = contentPadding ) } + +@Composable +private fun topAppBarColors(): TopAppBarColors { + return if (isBlurEnabled()) { + TopAppBarDefaults.topAppBarColors( + Color.Transparent, + Color.Transparent + ) + } else { + TopAppBarDefaults.topAppBarColors() + } +} + diff --git a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt index de5cbf3..1ccec1b 100644 --- a/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt +++ b/libs/ui/src/commonMain/kotlin/ru/shadowsparky/ui/theme/ComponentDefaults.kt @@ -1,56 +1,68 @@ 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.material3.AppBarWithSearchColors -import androidx.compose.material3.ExperimentalMaterial3Api 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.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.draw.clip +import androidx.compose.ui.focus.onFocusChanged import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.unit.dp -import ru.shadowsparky.ui.isBlurEnabled object ComponentDefaults { @Composable - fun topAppBarColors(): TopAppBarColors { - return if (isBlurEnabled()) { - TopAppBarDefaults.topAppBarColors( - Color.Transparent, - Color.Transparent + internal fun focusBorder( + modifier: Modifier, + needScale: Boolean + ): Modifier { + 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) +} diff --git a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt index f5f64c1..6f3e108 100644 --- a/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt +++ b/libs/ui/src/wasmJsMain/kotlin/ru/shadowsparky/ui/PageLifecycle.kt @@ -38,10 +38,10 @@ fun hookPageVisibility(lifecycle: LifecycleRegistry) { fun onPageHideShow(hidden: Boolean) { if (hidden) { - // уходим глубже: STARTED → CREATED when (lifecycle.state) { Lifecycle.State.RESUMED -> { - lifecycle.pause(); lifecycle.stop() + lifecycle.pause(); + lifecycle.stop() } Lifecycle.State.STARTED -> { @@ -51,14 +51,16 @@ fun hookPageVisibility(lifecycle: LifecycleRegistry) { else -> Unit } } else { - // pageshow: поднимемся обратно when (lifecycle.state) { 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.start(); if (pageVisibilityState() == "visible") lifecycle.resume() + lifecycle.start() + if (pageVisibilityState() == "visible") lifecycle.resume() } Lifecycle.State.STARTED -> {