Add spring morph overflow menus and polish auth/about UI.

Root overlay scales menus from the toolbar anchor (replaces Popup), with spring enter on service notices and incognito branding on the username step; desktop About no longer remounts an already visible window.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-11-08 20:32:12 +03:00
Unverified
parent 56eb2daad0
commit ca3d9fedc4
24 changed files with 1061 additions and 398 deletions
@@ -24,13 +24,17 @@ fun SplashHandoffContainer(
handoffActive: Boolean,
content: @Composable () -> Unit,
) {
val scale = remember { Animatable(SplashHandoffInitialScale) }
val scale = remember { Animatable(1f) }
LaunchedEffect(handoffActive) {
if (!handoffActive) return@LaunchedEffect
scale.animateTo(
targetValue = 1f,
animationSpec = handoffSpring,
)
if (handoffActive) {
scale.snapTo(SplashHandoffInitialScale)
scale.animateTo(
targetValue = 1f,
animationSpec = handoffSpring,
)
} else {
scale.snapTo(1f)
}
}
Box(
modifier = Modifier
@@ -622,7 +622,9 @@ fun main(args: Array<String>) {
}
fun openAbout() {
showMainWindow()
if (!windowVisible) {
showMainWindow()
}
DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout)
}
@@ -73,12 +73,12 @@
<string name="hide_password">Скрыть пароль</string>
<string name="auth_char_count">%1$d/%2$d</string>
<string name="auth_step_username_title">Введите имя пользователя</string>
<string name="auth_incognito_mode_title">Режим инкогнито</string>
<string name="auth_incognito_mode_title">Инкогнито</string>
<string name="auth_incognito_mode_summary">Вход остаётся приватным. Данные сессии шифруются и удаляются при выходе. Для api.fromchat.ru действуют дополнительные проверки HTTPS.</string>
<string name="auth_incognito_enabled_snackbar">Инкогнито включён для этой сессии.</string>
<string name="auth_incognito_login_title">Режим инкогнито</string>
<string name="auth_incognito_login_body">После закрытия ваша сессия и кэш будут удалены, а тот, кто воспользуется устройством после вас, вряд ли сможет что-то восстановить.</string>
<string name="auth_incognito_login_action">Режим инкогнито</string>
<string name="auth_incognito_login_action">Инкогнито</string>
<string name="auth_incognito_login_cta">Режим инкогнито</string>
<string name="auth_standard_login_action">Обычный вход</string>
<string name="incognito_chats_session_tooltip">Сейчас включён инкогнито. После закрытия приложения сессия и кэш будут удалены.</string>
@@ -337,6 +337,7 @@
<string name="server_config_unsupported_no_instance_id">Сервер не предоставляет корректный ID экземпляра. Подключение невозможно.</string>
<string name="server_config_snackbar_timeout">Сервер не ответил вовремя. Повторите попытку.</string>
<string name="server_config_checking">Проверка…</string>
<string name="server_config_info_category">Сервер</string>
<string name="server_config_info_instance">Экземпляр</string>
<string name="server_config_info_protocol">Протокол</string>
<string name="server_config_info_encryption">Шифрование</string>
@@ -354,7 +355,7 @@
<string name="action_wipe_local_cache_confirm_body">Будут удалены офлайн-сообщения и загрузки для всех экземпляров сервера на этом устройстве. Неотправленные сообщения из очереди будут потеряны.</string>
<string name="action_wipe_local_cache_done">Локальные данные очищены</string>
<string name="save_continue">Сохранить и продолжить</string>
<string name="change_server">Сменить сервер</string>
<string name="change_server">Сервер</string>
<string name="change_server_d">Подключиться к альтернативному серверу FromChat и выйти из аккаунта.</string>
<string name="logout">Выйти</string>
<string name="materialYou">Material You</string>
@@ -82,12 +82,12 @@
<string name="hide_password">Hide password</string>
<string name="auth_char_count">%1$d/%2$d</string>
<string name="auth_step_username_title">Enter your username</string>
<string name="auth_incognito_mode_title">Incognito mode</string>
<string name="auth_incognito_mode_title">Incognito</string>
<string name="auth_incognito_mode_summary">Keeps this sign-in private. Session data is encrypted and deleted when you quit. Extra HTTPS checks apply for api.fromchat.ru.</string>
<string name="auth_incognito_enabled_snackbar">Incognito is on for this session.</string>
<string name="auth_incognito_login_title">Incognito login</string>
<string name="auth_incognito_login_body">After you close the app, your session and cache will be deleted, and anyone using this device after you shouldn\u2019t be able to recover anything.</string>
<string name="auth_incognito_login_action">Incognito login</string>
<string name="auth_incognito_login_action">Incognito</string>
<string name="auth_incognito_login_cta">Incognito login</string>
<string name="auth_standard_login_action">Standard login</string>
<string name="incognito_chats_session_tooltip">Incognito is on. Closing the app clears this session and its cache.</string>
@@ -365,6 +365,7 @@
<string name="server_config_unsupported_no_instance_id">This server does not provide a valid instance ID. Cannot connect.</string>
<string name="server_config_snackbar_timeout">Server did not respond in time. Try again.</string>
<string name="server_config_checking">Checking…</string>
<string name="server_config_info_category">Server</string>
<string name="server_config_info_instance">Instance</string>
<string name="server_config_info_protocol">Protocol</string>
<string name="server_config_info_encryption">Encryption</string>
@@ -385,7 +386,7 @@
<string name="save_continue">Save and continue</string>
<!-- Settings -->
<string name="change_server">Change server</string>
<string name="change_server">Server</string>
<string name="change_server_d">Connect to an alternative FromChat server and sign out.</string>
<string name="logout">Log out</string>
<string name="materialYou">Material You</string>
@@ -66,7 +66,10 @@ object EncryptionModeNoticeStore {
val byId = LinkedHashMap<Int, Message>()
for (msg in existing) byId[msg.id] = msg
for (msg in service) {
if (byId.putIfAbsent(msg.id, msg) == null) changed = true
if (msg.id !in byId) {
byId[msg.id] = msg
changed = true
}
}
if (!changed) return@update current
current + (conversationId to sortMessagesForChatDisplay(byId.values.toList()))
@@ -79,7 +82,9 @@ object EncryptionModeNoticeStore {
if (notices.isEmpty()) return messages
val byId = LinkedHashMap<Int, Message>()
for (msg in messages) byId[msg.id] = msg
for (notice in notices) byId.putIfAbsent(notice.id, notice)
for (notice in notices) {
if (notice.id !in byId) byId[notice.id] = notice
}
return sortMessagesForChatDisplay(byId.values.toList())
}
@@ -15,6 +15,7 @@ import ru.fromchat.config.ServerConfig
import ru.fromchat.config.ServerConfigData
import ru.fromchat.config.Settings
import ru.fromchat.logging.Logger
import kotlin.concurrent.Volatile
sealed interface EncryptionModeChangeEvent {
val snapshot: InstanceEncryptionSnapshot
@@ -1,5 +1,9 @@
package ru.fromchat.api.instance
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout
import ru.fromchat.api.ApiClient
import ru.fromchat.api.local.cache.CacheContext
@@ -316,13 +320,17 @@ suspend fun applyServerConfig(
CacheContext.setActiveInstance(instanceId, userId)
}
private val serverApplyFollowUpScope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private suspend fun finishLoggedInSessionAfterServerApply(instanceId: String) {
when (bootstrapSessionInstance(hasToken = true, forceNetwork = true)) {
SessionBootstrapResult.Ready,
SessionBootstrapResult.OfflineCached,
-> {
ChatListSync.ensureStarted()
runCatching { ChatListSync.syncFromNetwork() }
serverApplyFollowUpScope.launch {
runCatching { ChatListSync.syncFromNetwork() }
}
}
SessionBootstrapResult.LogoutRequired -> {
runCatching { logoutIfInstanceUnsupported() }
@@ -354,8 +362,10 @@ suspend fun applyServerAndNavigate(
runCatching { InstanceEncryptionStore.refreshFromNetwork(config, apiBase) }
WebSocketManager.disconnect()
WebSocketManager.connect(forceRestart = true)
finishLoggedInSessionAfterServerApply(probe.instanceId)
onNavigateChat()
serverApplyFollowUpScope.launch {
runCatching { finishLoggedInSessionAfterServerApply(probe.instanceId) }
}
return ApplyServerResult.Applied
}
@@ -0,0 +1,50 @@
package ru.fromchat.incognito
import ru.fromchat.api.ApiClient
import ru.fromchat.api.instance.bootstrapSessionInstance
import ru.fromchat.api.local.WebSocketManager
import ru.fromchat.logging.Logger
import kotlin.concurrent.Volatile
private const val TAG = "IncognitoOverlaySession"
/**
* Incognito sign-in pushed from the chats list on top of an active normal session.
* In-memory auth and the back stack stay intact until login succeeds or the user backs out.
*/
object IncognitoOverlaySession {
@Volatile
var overlayActive: Boolean = false
private set
fun beginFromChats() {
if (IncognitoMode.active) return
if (overlayActive) return
if (ApiClient.token.isNullOrBlank()) return
overlayActive = true
Logger.i(TAG, "incognito auth from chats (keeping current session until login)")
}
suspend fun restoreNormalSessionAfterOverlayCancel() {
IncognitoMode.destroy()
if (!overlayActive) return
overlayActive = false
if (ApiClient.token.isNullOrBlank()) {
ApiClient.loadPersistedData()
val hasToken = ApiClient.token?.isNotBlank() == true
if (hasToken) {
runCatching {
bootstrapSessionInstance(hasToken = true, forceNetwork = false)
}
}
}
if (!ApiClient.token.isNullOrBlank()) {
WebSocketManager.connect(forceRestart = true)
}
Logger.i(TAG, "restored normal session after incognito auth cancel")
}
fun onOverlayIncognitoLoginSuccess() {
overlayActive = false
}
}
@@ -1,5 +1,6 @@
package ru.fromchat.ui
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
@@ -87,6 +88,7 @@ import ru.fromchat.logging.Logger
import ru.fromchat.notifications.NotificationLaunchCoordinator
import ru.fromchat.ui.auth.AUTH_INCOGNITO_ROUTE
import ru.fromchat.ui.auth.AUTH_ROUTE
import ru.fromchat.incognito.IncognitoOverlaySession
import ru.fromchat.ui.auth.AuthScreen
import ru.fromchat.ui.auth.resetPreAuthDraft
import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
@@ -105,6 +107,9 @@ import ru.fromchat.ui.chat.utils.appRootAttachmentDropTarget
import ru.fromchat.ui.components.HoverInfoOverlayController
import ru.fromchat.ui.components.HoverInfoOverlayHost
import ru.fromchat.ui.components.LocalHoverInfoOverlayController
import ru.fromchat.ui.components.LocalSpringOverflowMenuOverlayController
import ru.fromchat.ui.components.SpringOverflowMenuOverlayController
import ru.fromchat.ui.components.SpringOverflowMenuOverlayHost
import ru.fromchat.ui.components.provideSwitchHaptics
import ru.fromchat.ui.security.ConnectionSecurityDialogHost
import ru.fromchat.ui.main.ConversationListDetailShell
@@ -733,6 +738,7 @@ fun App(
val fullscreenImageController = remember { ChatFullscreenImageController() }
val hoverInfoOverlayController = remember { HoverInfoOverlayController() }
val springOverflowMenuOverlayController = remember { SpringOverflowMenuOverlayController() }
val hoverInfoHazeState = rememberHazeState()
var showReleaseNotesPrompt by remember { mutableStateOf(false) }
LaunchedEffect(startDestination) {
@@ -750,6 +756,7 @@ fun App(
LocalSystemBarsVisibility provides rememberSystemBarsController(),
LocalChatFullscreenImageController provides fullscreenImageController,
LocalHoverInfoOverlayController provides hoverInfoOverlayController,
LocalSpringOverflowMenuOverlayController provides springOverflowMenuOverlayController,
) {
if (startDestination != null) {
UiDisplayedEffect(onDisplayed = onUiDisplayed)
@@ -828,8 +835,15 @@ fun App(
}
composable(AUTH_INCOGNITO_ROUTE) {
val openedFromChats =
navController.previousBackStackEntry
?.destination
?.route == "chat"
AuthScreen(
onAuthSuccess = {
if (openedFromChats) {
IncognitoOverlaySession.onOverlayIncognitoLoginSuccess()
}
MainScope().launch {
runCatching {
bootstrapSessionInstance(
@@ -847,6 +861,7 @@ fun App(
},
onBackToWelcome = { navController.navigateUp() },
incognitoFlow = true,
incognitoOverlayFromChat = openedFromChats,
)
}
@@ -952,13 +967,16 @@ fun App(
) {
AppNavHost(Modifier.fillMaxSize())
// Observe back stack only after [AppNavHost] has set the graph.
val currentEntry by navController.currentBackStackEntryAsState()
val currentRoute = currentEntry?.destination?.route
val showConversationListDetail =
isDesktopListDetail && currentRoute == "chat"
if (showConversationListDetail) {
AnimatedVisibility(
visible = showConversationListDetail,
enter = mutedEnterTransition(rootNavPopEnterTransition()),
exit = mutedExitTransition(rootNavExitTransition()),
) {
val listPaneHazeState = rememberHazeState()
CompositionLocalProvider(
LocalPaneHazeState provides listPaneHazeState,
@@ -1019,6 +1037,7 @@ fun App(
hazeState = hoverInfoHazeState,
modifier = Modifier.fillMaxSize(),
)
SpringOverflowMenuOverlayHost(Modifier.fillMaxSize())
ChatFullscreenImageHost(Modifier.fillMaxSize())
CallOverlay(Modifier.fillMaxSize())
val aboutDocumentNavController =
@@ -19,11 +19,10 @@ import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.BugReport
import androidx.compose.material.icons.rounded.Login
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import ru.fromchat.ui.components.ChromeOverflowIconButton
import ru.fromchat.ui.components.SpringExpandingOverflowMenu
import ru.fromchat.ui.components.SpringDropdownMenuItem
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.TextCta
import androidx.compose.runtime.Composable
@@ -97,39 +96,39 @@ fun PreAuthOverflowMenu() {
val openAbout = LocalOpenAbout.current
var menuExpanded by remember { mutableStateOf(false) }
Box {
ChromeOverflowIconButton(onClick = { menuExpanded = true })
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.about)) },
leadingIcon = {
Icon(Icons.Rounded.Info, contentDescription = null)
},
onClick = {
menuExpanded = false
openAbout()
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_title)) },
leadingIcon = {
Icon(Icons.Rounded.BugReport, contentDescription = null)
},
onClick = {
menuExpanded = false
navController.navigate(SettingsRoutes.Logs)
},
)
}
SpringExpandingOverflowMenu(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it },
) {
SpringDropdownMenuItem(
text = { Text(stringResource(Res.string.about)) },
leadingIcon = {
Icon(Icons.Rounded.Info, contentDescription = null)
},
onClick = {
menuExpanded = false
openAbout()
},
)
SpringDropdownMenuItem(
text = { Text(stringResource(Res.string.logs_title)) },
leadingIcon = {
Icon(Icons.Rounded.BugReport, contentDescription = null)
},
onClick = {
menuExpanded = false
navController.navigate(SettingsRoutes.Logs)
},
)
}
}
/** Footer row below auth steps: server picker plus standard ↔ incognito sign-in routes. */
@Composable
internal fun AuthStepListFooter(incognitoFlow: Boolean) {
internal fun AuthStepListFooter(
incognitoFlow: Boolean,
hideAuthRouteSwitch: Boolean = false,
) {
val navController = LocalNavController.current
Row(
Modifier
@@ -144,12 +143,13 @@ internal fun AuthStepListFooter(incognitoFlow: Boolean) {
) {
TextCta(
onClick = { navController.navigate("serverConfig") },
modifier = Modifier.weight(1f),
modifier = if (hideAuthRouteSwitch) Modifier.fillMaxWidth() else Modifier.weight(1f),
leadingIcon = Icons.Rounded.Storage,
compact = true,
) {
Text(stringResource(Res.string.change_server))
}
if (hideAuthRouteSwitch) return@Row
if (incognitoFlow) {
TextCta(
onClick = {
@@ -40,6 +40,7 @@ import ru.fromchat.config.Settings
import ru.fromchat.config.shouldWarnPublicCleartextHttp
import ru.fromchat.incognito.findIncognitoMitmCertificateException
import ru.fromchat.incognito.IncognitoMode
import ru.fromchat.incognito.IncognitoOverlaySession
import ru.fromchat.logging.Logger
import ru.fromchat.Res
import ru.fromchat.ui.auth.captcha.SmartCaptchaLog
@@ -297,6 +298,7 @@ fun AuthScreen(
onAuthSuccess: () -> Unit,
onBackToWelcome: () -> Unit,
incognitoFlow: Boolean = false,
incognitoOverlayFromChat: Boolean = false,
) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
@@ -321,6 +323,12 @@ fun AuthScreen(
AuthRegisterDraft.incognitoEnabled = incognitoFlow
}
LaunchedEffect(incognitoOverlayFromChat) {
if (incognitoOverlayFromChat) {
IncognitoOverlaySession.beginFromChats()
}
}
DisposableEffect(Unit) {
ConnectionSecurityDialogController.onBypassConfirmed = {
incognitoTlsBypassRetryNonce++
@@ -365,8 +373,15 @@ fun AuthScreen(
val wrappedBackToWelcome: () -> Unit = {
AuthRegisterDraft.clear()
IncognitoMode.destroy()
onBackToWelcome()
if (incognitoOverlayFromChat) {
scope.launch {
IncognitoOverlaySession.restoreNormalSessionAfterOverlayCancel()
onBackToWelcome()
}
} else {
IncognitoMode.destroy()
onBackToWelcome()
}
}
val resetToUsername: () -> Unit = {
@@ -524,6 +539,7 @@ fun AuthScreen(
onUsernameChange = { username = it },
incognitoEnabled = incognitoFlow,
incognitoFlow = incognitoFlow,
hideAuthRouteSwitch = incognitoOverlayFromChat,
mitmBypassRetryNonce = incognitoTlsBypassRetryNonce,
publicHttpBypassRetryNonce = publicHttpBypassRetryNonce,
onContinue = {
@@ -555,6 +571,7 @@ fun AuthScreen(
password = password,
onPasswordChange = { password = it },
incognitoFlow = incognitoFlow,
hideAuthRouteSwitch = incognitoOverlayFromChat,
onLoginSuccess = wrappedAuthSuccess,
onNeedsRegister = { required, params, captchaReq, captcha ->
Logger.i(
@@ -27,9 +27,7 @@ import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import ru.fromchat.Res
import ru.fromchat.auth_rate_limit
import ru.fromchat.auth_incognito_login_body
import ru.fromchat.auth_incognito_login_cta
import ru.fromchat.auth_incognito_login_title
import ru.fromchat.auth_step_password_body
import ru.fromchat.auth_step_password_title
import ru.fromchat.auth_wrong_password
@@ -62,6 +60,7 @@ internal fun passwordStepPage(
password: String,
onPasswordChange: (String) -> Unit,
incognitoFlow: Boolean,
hideAuthRouteSwitch: Boolean = false,
onLoginSuccess: () -> Unit,
onNeedsRegister: suspend (
yandexRequired: Boolean,
@@ -82,13 +81,9 @@ internal fun passwordStepPage(
val loginLabel = stringResource(
if (incognitoFlow) Res.string.auth_incognito_login_cta else Res.string.login,
)
val heroContainerColor =
if (incognitoFlow) colorScheme.surfaceContainerHigh
else colorScheme.secondaryContainer
val heroContentColor =
if (incognitoFlow) colorScheme.onSurfaceVariant
else colorScheme.onSecondaryContainer
val heroIcon = if (incognitoFlow) Icons.Rounded.VisibilityOff else Icons.Rounded.Lock
val heroContainerColor = colorScheme.secondaryContainer
val heroContentColor = colorScheme.onSecondaryContainer
val heroIcon = Icons.Rounded.Lock
fun attemptSubmit() {
if (busy) return
@@ -153,14 +148,8 @@ internal fun passwordStepPage(
var visible by rememberSaveable { mutableStateOf(false) }
ExpressiveStepPageHeader(
title = stringResource(
if (incognitoFlow) Res.string.auth_incognito_login_title
else Res.string.auth_step_password_title,
),
body = stringResource(
if (incognitoFlow) Res.string.auth_incognito_login_body
else Res.string.auth_step_password_body,
),
title = stringResource(Res.string.auth_step_password_title),
body = stringResource(Res.string.auth_step_password_body),
)
OutlinedTextField(
value = password,
@@ -190,7 +179,12 @@ internal fun passwordStepPage(
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { AuthStepListFooter(incognitoFlow = incognitoFlow) },
listFooter = {
AuthStepListFooter(
incognitoFlow = incognitoFlow,
hideAuthRouteSwitch = hideAuthRouteSwitch,
)
},
button = {
ActionButton(
onClick = ::attemptSubmit,
@@ -71,6 +71,7 @@ internal fun usernameStepPage(
onUsernameChange: (String) -> Unit,
incognitoEnabled: Boolean,
incognitoFlow: Boolean,
hideAuthRouteSwitch: Boolean = false,
mitmBypassRetryNonce: Int,
publicHttpBypassRetryNonce: Int,
onContinue: suspend () -> Unit,
@@ -193,13 +194,15 @@ internal fun usernameStepPage(
}
}
val incognitoFirstStep = incognitoFlow || incognitoEnabled
val heroContainerColor =
if (incognitoFlow) colorScheme.surfaceContainerHigh
if (incognitoFirstStep) colorScheme.surfaceContainerHigh
else colorScheme.primaryContainer
val heroContentColor =
if (incognitoFlow) colorScheme.onSurfaceVariant
if (incognitoFirstStep) colorScheme.onSurfaceVariant
else colorScheme.onPrimaryContainer
val heroIcon = if (incognitoFlow) Icons.Rounded.VisibilityOff else Icons.Rounded.Person
val heroIcon =
if (incognitoFirstStep) Icons.Rounded.VisibilityOff else Icons.Rounded.Person
return ExpressiveStepPage(
hero = ExpressiveHeroSpec(
@@ -215,11 +218,11 @@ internal fun usernameStepPage(
ExpressiveStepPageHeader(
title = stringResource(
if (incognitoFlow) Res.string.auth_incognito_login_title
if (incognitoFirstStep) Res.string.auth_incognito_login_title
else Res.string.auth_step_username_title,
),
body = stringResource(
if (incognitoFlow) Res.string.auth_incognito_login_body
if (incognitoFirstStep) Res.string.auth_incognito_login_body
else Res.string.auth_step_username_body,
),
)
@@ -247,7 +250,12 @@ internal fun usernameStepPage(
shape = SettingsPasswordOutlineFieldShape,
)
},
listFooter = { AuthStepListFooter(incognitoFlow = incognitoFlow) },
listFooter = {
AuthStepListFooter(
incognitoFlow = incognitoFlow,
hideAuthRouteSwitch = hideAuthRouteSwitch,
)
},
button = {
ActionButton(
onClick = ::attemptSubmit,
@@ -0,0 +1,70 @@
package ru.fromchat.ui.chat.chrome
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.core.FastOutSlowInEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.LoadingIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
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 kotlinx.coroutines.delay
private const val CHAT_LOAD_COVER_HOLD_AFTER_LOAD_MS = 250L
private const val CHAT_LOAD_COVER_FADE_OUT_MS = 360
/**
* Keeps the load cover visible until [isInitialLoadActive] is false, then holds for
* [CHAT_LOAD_COVER_HOLD_AFTER_LOAD_MS] before hiding (fade-out runs in [ChatInitialLoadCover]).
*/
@Composable
fun rememberChatInitialLoadCoverVisible(isInitialLoadActive: Boolean): Boolean {
var coverVisible by remember { mutableStateOf(false) }
LaunchedEffect(isInitialLoadActive) {
if (isInitialLoadActive) {
coverVisible = true
} else if (coverVisible) {
delay(CHAT_LOAD_COVER_HOLD_AFTER_LOAD_MS)
if (!isInitialLoadActive) {
coverVisible = false
}
}
}
return coverVisible
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
@Composable
fun ChatInitialLoadCover(
visible: Boolean,
modifier: Modifier = Modifier,
) {
AnimatedVisibility(
visible = visible,
modifier = modifier,
enter = EnterTransition.None,
exit = fadeOut(
animationSpec = tween(CHAT_LOAD_COVER_FADE_OUT_MS, easing = FastOutSlowInEasing),
),
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.surface),
contentAlignment = Alignment.Center,
) {
LoadingIndicator()
}
}
}
@@ -40,7 +40,6 @@ import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
@@ -450,7 +449,9 @@ fun ChatScreen(
val focusManager = LocalFocusManager.current
val profileUserId = panelState.profileUserId
val hazeState = rememberHazeState()
val hazeBlurEnabled = !(panelState.isLoading && panelState.messages.isEmpty())
val showInitialChatLoad = panelState.isLoading && panelState.messages.isEmpty()
val initialLoadCoverVisible = rememberChatInitialLoadCoverVisible(showInitialChatLoad)
val hazeBlurEnabled = !showInitialChatLoad
val listDetailActive = LocalConversationListDetailActive.current
val detailContentPad =
if (listDetailActive) ConversationDetailContentPadding else 0.dp
@@ -1216,15 +1217,8 @@ fun ChatScreen(
},
),
) {
if (panelState.isLoading && panelState.messages.isEmpty()) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} else {
Box(modifier = Modifier.fillMaxSize()) {
Box(modifier = Modifier.fillMaxSize()) {
if (!showInitialChatLoad) {
LazyColumn(
state = listState,
modifier = Modifier
@@ -1474,8 +1468,9 @@ fun ChatScreen(
item { Spacer(modifier.height(floatingHeaderClearance)) }
}
}
if (panelState.isLoadingMore) {
if (panelState.isLoadingMore) {
Box(
modifier = Modifier
.align(Alignment.TopCenter)
@@ -1589,7 +1584,6 @@ fun ChatScreen(
},
modifier = Modifier.align(Alignment.TopCenter),
)
}
}
BoxWithConstraints(
@@ -1667,6 +1661,13 @@ fun ChatScreen(
}
}
ChatInitialLoadCover(
visible = initialLoadCoverVisible,
modifier = Modifier
.fillMaxSize()
.zIndex(20f),
)
val fullscreenHost = LocalChatFullscreenImageController.current
DisposableEffect(fullscreenHost) {
onDispose { fullscreenHost?.request = null }
@@ -27,12 +27,11 @@ import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.width
import androidx.compose.material.icons.Icons
import ru.fromchat.ui.components.ChromeBackIconButton
import ru.fromchat.ui.components.ChromeOverflowIconButton
import androidx.compose.material.icons.automirrored.rounded.Reply
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.SaveAlt
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import ru.fromchat.ui.components.SpringDropdownMenuItem
import ru.fromchat.ui.components.SpringExpandingOverflowMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -797,63 +796,66 @@ fun ImageFullscreenPreview(
)
}
var menuExpanded by remember { mutableStateOf(false) }
Box {
ChromeOverflowIconButton(
onClick = { menuExpanded = true },
contentDescription = cdMenu,
)
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
modifier = Modifier.background(Color.Black.copy(alpha = MENU_BG_ALPHA))
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.AutoMirrored.Rounded.Reply, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(labelReply, color = Color.White)
}
},
SpringExpandingOverflowMenu(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it },
containerColor = Color.Black.copy(alpha = MENU_BG_ALPHA),
buttonContainerColor = Color.Black.copy(alpha = MENU_BG_ALPHA),
buttonContentColor = Color.White,
) {
val menuContentColor = Color.White
SpringDropdownMenuItem(
text = { Text(labelReply, color = menuContentColor) },
leadingIcon = {
Icon(
Icons.AutoMirrored.Rounded.Reply,
contentDescription = null,
tint = menuContentColor,
)
},
contentColor = menuContentColor,
onClick = {
menuExpanded = false
onReply(message)
dismissRequested = true
}
},
)
if (isMessageImageFullyLoaded(message, fileIndex)) {
SpringDropdownMenuItem(
text = { Text(labelSave, color = menuContentColor) },
leadingIcon = {
Icon(
Icons.Rounded.SaveAlt,
contentDescription = null,
tint = menuContentColor,
)
},
contentColor = menuContentColor,
onClick = {
menuExpanded = false
onSave(message, fileIndex)
},
)
}
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
SpringDropdownMenuItem(
text = { Text(labelDelete, color = menuContentColor) },
leadingIcon = {
Icon(
Icons.Rounded.Delete,
contentDescription = null,
tint = menuContentColor,
)
},
contentColor = menuContentColor,
onClick = {
menuExpanded = false
onDelete(message)
dismissRequested = true
},
)
if (isMessageImageFullyLoaded(message, fileIndex)) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.SaveAlt, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(labelSave, color = Color.White)
}
},
onClick = {
menuExpanded = false
onSave(message, fileIndex)
},
)
}
if (currentUserId != null &&
(message.user_id == currentUserId || currentUserId == 1)
) {
DropdownMenuItem(
text = {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Rounded.Delete, null, tint = Color.White)
Spacer(Modifier.width(8.dp))
Text(labelDelete, color = Color.White)
}
},
onClick = {
menuExpanded = false
onDelete(message)
dismissRequested = true
},
)
}
}
}
}
@@ -23,6 +23,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
@@ -43,6 +44,7 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
@@ -220,6 +222,74 @@ private fun Modifier.enterBubbleScale(
}
}
private val serviceNoticeEnterSpring = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
)
@Composable
private fun CenteredServiceNotice(
text: String,
messageId: Int,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
val progress = remember(messageId) { Animatable(0f) }
var measuredHeightPx by remember(messageId) { mutableIntStateOf(0) }
val density = LocalDensity.current
LaunchedEffect(messageId) {
progress.snapTo(0f)
progress.animateTo(1f, serviceNoticeEnterSpring)
}
val scale = 0.82f + 0.18f * progress.value
val alpha = progress.value
val revealHeight =
if (measuredHeightPx > 0) with(density) { (measuredHeightPx * progress.value).toDp() }
else 0.dp
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
if (measuredHeightPx == 0) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier
.graphicsLayer { this.alpha = 0f }
.onGloballyPositioned { measuredHeightPx = it.size.height },
)
} else {
Box(
modifier = Modifier
.fillMaxWidth()
.height(revealHeight)
.clipToBounds(),
contentAlignment = Alignment.Center,
) {
Text(
text = text,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurface,
textAlign = TextAlign.Center,
modifier = Modifier.graphicsLayer {
scaleX = scale
scaleY = scale
this.alpha = alpha
transformOrigin = TransformOrigin.Center
},
)
}
}
}
}
@Composable
fun MessageItem(
message: Message,
@@ -252,20 +322,11 @@ fun MessageItem(
onExitAnimationFinished: (() -> Unit)? = null,
) {
if (message.isCenteredServiceNotice()) {
val scheme = MaterialTheme.colorScheme
Box(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 10.dp, horizontal = 24.dp),
contentAlignment = Alignment.Center,
) {
Text(
text = message.content,
style = MaterialTheme.typography.bodySmall,
color = scheme.onSurface,
textAlign = TextAlign.Center,
)
}
CenteredServiceNotice(
text = message.content,
messageId = message.id,
modifier = modifier,
)
return
}
@@ -66,7 +66,7 @@ fun HazeBottomBar(
.fillMaxWidth()
.navigationBarsPadding()
.padding(horizontal = SettingsStepHorizontalPadding)
.padding(top = 2.dp, bottom = 4.dp),
.padding(top = 2.dp, bottom = 16.dp),
) {
content()
}
@@ -0,0 +1,263 @@
package ru.fromchat.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.spring
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
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.draw.alpha
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import ru.fromchat.ui.components.BackHandler
import androidx.compose.ui.layout.SubcomposeLayout
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import ru.fromchat.utils.currentWindowSize
import ru.fromchat.utils.scaleOnPress
internal val springDropdownMenuShape = RoundedCornerShape(16.dp)
private val springDropdownItemShape = RoundedCornerShape(12.dp)
private val springDropdownEnterSpring = spring<Float>(
dampingRatio = Spring.DampingRatioLowBouncy,
stiffness = Spring.StiffnessMediumLow,
)
private val menuChromePadding = 4.dp
/** ⋮ overflow anchored in chrome; menu morphs on [SpringOverflowMenuOverlayHost] (top-end spring scale). */
@Composable
fun SpringExpandingOverflowMenu(
expanded: Boolean,
onExpandedChange: (Boolean) -> Unit,
modifier: Modifier = Modifier,
containerColor: Color = MaterialTheme.colorScheme.surfaceContainerLow,
buttonContainerColor: Color? = null,
buttonContentColor: Color? = null,
content: @Composable ColumnScope.() -> Unit,
) {
val chromeColors = chromeFilledIconButtonColors()
val resolvedButtonContainer = buttonContainerColor ?: chromeColors.containerColor
val resolvedButtonContent = buttonContentColor ?: chromeColors.contentColor
val density = LocalDensity.current
val progress = remember { Animatable(0f) }
var menuSize by remember { mutableStateOf(IntSize.Zero) }
var triggerSize by remember { mutableStateOf(IntSize.Zero) }
var anchorTopRightInRoot by remember { mutableStateOf(Offset.Zero) }
val overlay = LocalSpringOverflowMenuOverlayController.current
val maxMenuWidthPx = with(density) {
val screenW = currentWindowSize().width
minOf(screenW - 32.dp.roundToPx(), 320.dp.roundToPx())
}
SubcomposeLayout(Modifier.size(0.dp)) { _ ->
val triggerPlaceable = subcompose("trigger") {
ChromeOverflowIconButton(onClick = {})
}.firstOrNull()?.measure(Constraints())
if (triggerPlaceable != null && triggerPlaceable.width > 0 && triggerPlaceable.height > 0) {
val trigger = IntSize(triggerPlaceable.width, triggerPlaceable.height)
if (trigger != triggerSize) triggerSize = trigger
}
val menuPlaceable = subcompose("measure") {
SpringOverflowMenuPanel(
content = content,
fixedSize = menuSize.takeIf { it != IntSize.Zero },
)
}.firstOrNull()
val measured = menuPlaceable?.measure(
if (menuSize != IntSize.Zero) {
Constraints.fixed(menuSize.width, menuSize.height)
} else {
Constraints(
minWidth = 0,
maxWidth = maxMenuWidthPx,
minHeight = 0,
maxHeight = Constraints.Infinity,
)
},
)
if (measured != null && measured.width > 0 && measured.height > 0) {
val size = IntSize(measured.width, measured.height)
if (size != menuSize) menuSize = size
}
layout(0, 0) {}
}
LaunchedEffect(expanded) {
if (menuSize == IntSize.Zero) return@LaunchedEffect
when {
expanded -> {
progress.snapTo(0f)
progress.animateTo(1f, springDropdownEnterSpring)
}
progress.value > 0f -> progress.animateTo(0f, springDropdownEnterSpring)
}
}
val p = progress.value
val chipAlpha = (1f - p * 1.35f).coerceIn(0f, 1f)
val morphing = expanded || p > 0.001f
val fallbackCollapsePx = with(density) { 40.dp.roundToPx() }
val collapseW = when {
triggerSize.width > 1 -> triggerSize.width
else -> fallbackCollapsePx
}
val collapseH = when {
triggerSize.height > 1 -> triggerSize.height
else -> fallbackCollapsePx
}
val startW = collapseW.coerceAtLeast(1)
val startH = collapseH.coerceAtLeast(1)
BackHandler(enabled = expanded) { onExpandedChange(false) }
Box(
modifier = modifier
.wrapContentSize(Alignment.TopEnd)
.onGloballyPositioned { coordinates ->
val root = coordinates.positionInRoot()
anchorTopRightInRoot = Offset(
x = root.x + coordinates.size.width,
y = root.y,
)
val size = coordinates.size
if (size.width > 0 && size.height > 0) {
triggerSize = size
}
},
contentAlignment = Alignment.TopEnd,
) {
ChromeOverflowIconButton(
onClick = { if (!expanded) onExpandedChange(true) },
enabled = !expanded,
modifier = Modifier.graphicsLayer {
alpha = if (expanded) chipAlpha else 1f
},
colors = chromeFilledIconButtonColors(
containerColor = resolvedButtonContainer,
contentColor = resolvedButtonContent,
),
)
}
DisposableEffect(overlay) {
onDispose { overlay?.clear() }
}
if (overlay != null) {
SideEffect {
if (morphing && menuSize != IntSize.Zero) {
overlay.publish(
SpringOverflowMenuOverlaySlot(
anchorTopRightInRoot = anchorTopRightInRoot,
onDismiss = { onExpandedChange(false) },
menuSize = menuSize,
startW = startW,
startH = startH,
progress = p,
containerColor = containerColor,
content = content,
),
)
} else {
overlay.clear()
}
}
}
}
@Composable
internal fun SpringOverflowMenuPanel(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit,
fixedSize: IntSize? = null,
) {
val widthMod = if (fixedSize != null && fixedSize.width > 0) {
Modifier.size(
width = with(LocalDensity.current) { fixedSize.width.toDp() },
height = with(LocalDensity.current) { fixedSize.height.toDp() },
)
} else {
Modifier.width(IntrinsicSize.Max)
}
CompositionLocalProvider(LocalContentColor provides MaterialTheme.colorScheme.onSurface) {
Column(
modifier = modifier.then(widthMod).padding(menuChromePadding),
verticalArrangement = Arrangement.spacedBy(2.dp),
content = content,
)
}
}
@Composable
fun SpringDropdownMenuItem(
text: @Composable () -> Unit,
onClick: () -> Unit,
modifier: Modifier = Modifier,
leadingIcon: (@Composable () -> Unit)? = null,
trailingIcon: (@Composable () -> Unit)? = null,
enabled: Boolean = true,
contentColor: Color = LocalContentColor.current,
) {
val itemAlpha = if (enabled) 1f else 0.38f
Box(
modifier = modifier
.fillMaxWidth()
.graphicsLayer {
clip = true
shape = springDropdownItemShape
alpha = itemAlpha
}
.scaleOnPress(
scale = 0.96f,
onClick = if (enabled) onClick else null,
animationSpec = spring(
dampingRatio = Spring.DampingRatioMediumBouncy,
stiffness = Spring.StiffnessMedium,
),
)
.padding(horizontal = 12.dp, vertical = 10.dp),
contentAlignment = Alignment.CenterStart,
) {
CompositionLocalProvider(LocalContentColor provides contentColor) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
leadingIcon?.invoke()
Box(modifier = Modifier.weight(1f, fill = false)) {
text()
}
trailingIcon?.invoke()
}
}
}
}
@@ -0,0 +1,133 @@
package ru.fromchat.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.size
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.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.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInRoot
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.dp
import kotlin.math.roundToInt
/** Active spring overflow menu rendered by [SpringOverflowMenuOverlayHost]. */
class SpringOverflowMenuOverlaySlot(
val anchorTopRightInRoot: Offset,
val onDismiss: () -> Unit,
val menuSize: IntSize,
val startW: Int,
val startH: Int,
val progress: Float,
val containerColor: Color,
val content: @Composable ColumnScope.() -> Unit,
)
class SpringOverflowMenuOverlayController {
var slot by mutableStateOf<SpringOverflowMenuOverlaySlot?>(null)
private set
fun publish(slot: SpringOverflowMenuOverlaySlot?) {
this.slot = slot
}
fun clear() {
slot = null
}
}
val LocalSpringOverflowMenuOverlayController =
staticCompositionLocalOf<SpringOverflowMenuOverlayController?> { null }
@Composable
fun SpringOverflowMenuOverlayHost(modifier: Modifier = Modifier) {
val controller = LocalSpringOverflowMenuOverlayController.current ?: return
val slot = controller.slot ?: return
val density = LocalDensity.current
var hostOriginInRoot by remember { mutableStateOf(Offset.Zero) }
val targetWdp = with(density) { slot.menuSize.width.toDp() }
val targetHdp = with(density) { slot.menuSize.height.toDp() }
val scaleFromX = slot.startW.toFloat() / slot.menuSize.width.toFloat()
val scaleFromY = slot.startH.toFloat() / slot.menuSize.height.toFloat()
val p = slot.progress
val morphScaleX = scaleFromX + (1f - scaleFromX) * p
val morphScaleY = scaleFromY + (1f - scaleFromY) * p
val panelBgAlpha = ((p - 0.18f) / 0.82f).coerceIn(0f, 1f)
val itemsAlpha = ((p - 0.32f) / 0.68f).coerceIn(0f, 1f)
val shadowElevationPx = with(density) { (8.dp * p).toPx() }
val panelShape = springDropdownMenuShape
val localAnchorTopRight = slot.anchorTopRightInRoot - hostOriginInRoot
val menuX = (localAnchorTopRight.x - slot.menuSize.width).roundToInt()
val menuY = localAnchorTopRight.y.roundToInt()
val scrimInteraction = remember { MutableInteractionSource() }
Box(
modifier = modifier
.fillMaxSize()
.onGloballyPositioned { coords ->
hostOriginInRoot = coords.positionInRoot()
},
) {
Box(
modifier = Modifier
.fillMaxSize()
.clickable(
interactionSource = scrimInteraction,
indication = null,
onClick = slot.onDismiss,
),
)
Box(
modifier = Modifier
.align(Alignment.TopStart)
.offset { IntOffset(menuX, menuY) }
.size(targetWdp, targetHdp)
.graphicsLayer {
scaleX = morphScaleX
scaleY = morphScaleY
transformOrigin = TransformOrigin(1f, 0f)
shape = panelShape
clip = true
shadowElevation = shadowElevationPx
}
.clip(panelShape),
) {
Box(
modifier = Modifier
.fillMaxSize()
.alpha(panelBgAlpha)
.background(slot.containerColor),
)
if (itemsAlpha > 0.01f) {
SpringOverflowMenuPanel(
modifier = Modifier
.size(targetWdp, targetHdp)
.alpha(itemsAlpha),
content = slot.content,
fixedSize = slot.menuSize,
)
}
}
}
}
@@ -41,8 +41,8 @@ import androidx.compose.material.icons.rounded.Search
import androidx.compose.material.icons.rounded.Delete
import androidx.compose.material.icons.rounded.VisibilityOff
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import ru.fromchat.ui.components.SpringDropdownMenuItem
import ru.fromchat.ui.components.SpringExpandingOverflowMenu
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
@@ -132,6 +132,7 @@ import ru.fromchat.chats_selected_count
import ru.fromchat.config.ServerConfig
import ru.fromchat.incognito.IncognitoMode
import ru.fromchat.auth_incognito_mode_title
import ru.fromchat.ui.auth.AUTH_INCOGNITO_ROUTE
import ru.fromchat.incognito_chats_session_tooltip
import ru.fromchat.notifications.LocalizedChatTitles
import ru.fromchat.public_chat
@@ -380,10 +381,12 @@ private fun ChatsNormalTopBar(
searchCollapseProgress: Float,
searchContentDescription: String,
onSearchClick: () -> Unit,
onIncognitoOverlayLogin: () -> Unit,
blurEnabled: Boolean,
modifier: Modifier = Modifier,
) {
val tooltipTitle = stringResource(Res.string.auth_incognito_mode_title)
var logoMenuExpanded by remember { mutableStateOf(false) }
HoverInfoOverlay(
enabled = IncognitoMode.active,
@@ -418,7 +421,6 @@ private fun ChatsNormalTopBar(
Spacer(modifier = Modifier.width(10.dp))
AnimatedContent(
targetState = titleKey,
modifier = Modifier.weight(1f),
transitionSpec = {
(slideInVertically { it / 2 } + fadeIn()) togetherWith
(slideOutVertically { -it / 2 } + fadeOut())
@@ -456,20 +458,24 @@ private fun ChatsNormalTopBar(
}
},
actions = {
IconButton(
onClick = onSearchClick,
modifier = Modifier.graphicsLayer {
alpha = searchCollapseProgress.coerceIn(0f, 1f)
},
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = searchContentDescription,
)
if (searchCollapseProgress > 0.01f) {
IconButton(
onClick = onSearchClick,
modifier = Modifier.graphicsLayer {
alpha = searchCollapseProgress.coerceIn(0f, 1f)
},
) {
Icon(
imageVector = Icons.Rounded.Search,
contentDescription = searchContentDescription,
)
}
}
if (IncognitoMode.active) {
Box(
modifier = anchorModifier.size(48.dp),
modifier = anchorModifier
.padding(end = 4.dp)
.size(48.dp),
contentAlignment = Alignment.Center,
) {
Icon(
@@ -479,6 +485,23 @@ private fun ChatsNormalTopBar(
tint = MaterialTheme.colorScheme.primary,
)
}
} else {
SpringExpandingOverflowMenu(
expanded = logoMenuExpanded,
onExpandedChange = { logoMenuExpanded = it },
modifier = Modifier.padding(end = 4.dp),
) {
SpringDropdownMenuItem(
text = { Text(stringResource(Res.string.auth_incognito_mode_title)) },
leadingIcon = {
Icon(Icons.Rounded.VisibilityOff, contentDescription = null)
},
onClick = {
logoMenuExpanded = false
onIncognitoOverlayLogin()
},
)
}
}
},
)
@@ -535,25 +558,17 @@ private fun ChatsSelectionTopBar(
}
}
if (bulkActions.canMarkRead) {
Box {
IconButton(onClick = { overflowOpen = true }) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = moreActionsCd,
)
}
DropdownMenu(
expanded = overflowOpen,
onDismissRequest = { overflowOpen = false },
) {
DropdownMenuItem(
text = { Text(markReadLabel) },
onClick = {
overflowOpen = false
onMarkRead()
},
)
}
SpringExpandingOverflowMenu(
expanded = overflowOpen,
onExpandedChange = { overflowOpen = it },
) {
SpringDropdownMenuItem(
text = { Text(markReadLabel) },
onClick = {
overflowOpen = false
onMarkRead()
},
)
}
}
},
@@ -571,6 +586,7 @@ fun ChatsTab(
) {
val navController =
LocalDesktopChatsNavController.current ?: LocalNavController.current
val rootNavController = LocalNavController.current
val clipboard = supportClipboardManagerImpl
val haptic = rememberHapticFeedback()
val scope = rememberCoroutineScope()
@@ -1154,6 +1170,11 @@ fun ChatsTab(
searchCollapseProgress = topBarSearchReveal,
searchContentDescription = searchBarHint,
onSearchClick = onOpenSearch,
onIncognitoOverlayLogin = {
rootNavController.navigate(AUTH_INCOGNITO_ROUTE) {
launchSingleTop = true
}
},
blurEnabled = isVisible,
modifier = Modifier.graphicsLayer { alpha = 1f - selectionProgress },
)
@@ -72,8 +72,8 @@ import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.DatePicker
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import ru.fromchat.ui.components.SpringDropdownMenuItem
import ru.fromchat.ui.components.SpringExpandingOverflowMenu
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.FilledTonalButton
@@ -835,66 +835,56 @@ fun LogsScreen() {
)
}
}
LogsTopBarChromeIconButton(
onClick = { menuExpanded = true },
enabled = searchProgress < 1f,
contentDescription = stringResource(Res.string.more),
SpringExpandingOverflowMenu(
expanded = menuExpanded,
onExpandedChange = { menuExpanded = it },
) {
Icon(
imageVector = Icons.Rounded.MoreVert,
contentDescription = stringResource(Res.string.more),
)
}
}
DropdownMenu(
expanded = menuExpanded,
onDismissRequest = { menuExpanded = false },
) {
DropdownMenuItem(
text = { Text(logsSearchLabel) },
leadingIcon = {
Icon(Icons.Rounded.Search, contentDescription = null)
},
onClick = {
menuExpanded = false
enterSearchMode()
},
)
if (isViewingCurrent) {
DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_rotate)) },
SpringDropdownMenuItem(
text = { Text(logsSearchLabel) },
leadingIcon = {
Icon(Icons.Rounded.Sync, contentDescription = null)
Icon(Icons.Rounded.Search, contentDescription = null)
},
onClick = {
menuExpanded = false
showRotateConfirm = true
},
)
DropdownMenuItem(
text = { Text(stringResource(Res.string.logs_clean)) },
leadingIcon = {
Icon(Icons.Rounded.CleaningServices, contentDescription = null)
},
onClick = {
menuExpanded = false
showCleanSheet = true
},
)
} else {
DropdownMenuItem(
text = { Text(deleteLabel) },
leadingIcon = {
Icon(Icons.Rounded.Delete, contentDescription = null)
},
onClick = {
menuExpanded = false
viewingFilePath?.let { path ->
pendingDeletePaths = setOf(path)
showDeleteFileConfirm = true
}
enterSearchMode()
},
)
if (isViewingCurrent) {
SpringDropdownMenuItem(
text = { Text(stringResource(Res.string.logs_rotate)) },
leadingIcon = {
Icon(Icons.Rounded.Sync, contentDescription = null)
},
onClick = {
menuExpanded = false
showRotateConfirm = true
},
)
SpringDropdownMenuItem(
text = { Text(stringResource(Res.string.logs_clean)) },
leadingIcon = {
Icon(Icons.Rounded.CleaningServices, contentDescription = null)
},
onClick = {
menuExpanded = false
showCleanSheet = true
},
)
} else {
SpringDropdownMenuItem(
text = { Text(deleteLabel) },
leadingIcon = {
Icon(Icons.Rounded.Delete, contentDescription = null)
},
onClick = {
menuExpanded = false
viewingFilePath?.let { path ->
pendingDeletePaths = setOf(path)
showDeleteFileConfirm = true
}
},
)
}
}
}
}
@@ -254,7 +254,9 @@ fun ServerConfigScreen(
) {
ApplyServerResult.Applied -> Unit
ApplyServerResult.ServerUnreachable -> {
snackbarHostState.showSnackbar(strSnackbarApiFail)
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strSnackbarApiFail)
}
}
}
}
@@ -373,70 +375,90 @@ fun ServerConfigScreen(
scope.launch {
try {
busy = true
val parsed = buildTentativeFromParsed()
if (parsed == null) {
snackbarHostState.showSnackbar(strHostError)
return@launch
}
val endpointOnly = ServerConfigData(
serverIp = parsed.host,
apiPort = parsed.port,
callsPort = parsed.port,
httpsEnabled = parsed.httpsPreferred,
callsEnabled = false,
)
if (preAuth) {
persistPreAuthServerEndpoint(endpointOnly)
}
val (tentative, probe) = try {
runServerEndpointProbe(parsed)
} catch (e: Exception) {
snackbarHostState.showSnackbar(strSnackbarApiFail)
return@launch
}
lastProbe = probe
lastProbedConfig = tentative
if (
probe == ServerProbeResult.Unreachable &&
surfaceLastIncognitoTlsProbeFailure(parsed.host, parsed.httpsPreferred)
) {
return@launch
}
if (
ConnectionSecurityDialogController.showMitmDialog ||
ConnectionSecurityDialogController.showTlsDialog ||
ConnectionSecurityDialogController.showPublicHttpDialog
) {
return@launch
}
when (probe) {
ServerProbeResult.Unsupported -> {
snackbarHostState.showSnackbar(
strUnsupportedInstance
)
withContext(Dispatchers.Default) {
val parsed = buildTentativeFromParsed()
if (parsed == null) {
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strHostError)
}
return@withContext
}
val endpointOnly = ServerConfigData(
serverIp = parsed.host,
apiPort = parsed.port,
callsPort = parsed.port,
httpsEnabled = parsed.httpsPreferred,
callsEnabled = false,
)
if (preAuth) {
persistPreAuthServerEndpoint(endpointOnly)
}
val (tentative, probe) = try {
runServerEndpointProbe(parsed)
} catch (e: Exception) {
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strSnackbarApiFail)
}
return@withContext
}
withContext(Dispatchers.Main) {
lastProbe = probe
lastProbedConfig = tentative
}
ServerProbeResult.Timeout -> {
snackbarHostState.showSnackbar(strSnackbarTimeout)
if (
probe == ServerProbeResult.Unreachable &&
withContext(Dispatchers.Main) {
surfaceLastIncognitoTlsProbeFailure(
parsed.host,
parsed.httpsPreferred,
)
}
) {
return@withContext
}
ServerProbeResult.Unreachable -> {
snackbarHostState.showSnackbar(strSnackbarApiFail)
if (
withContext(Dispatchers.Main) {
ConnectionSecurityDialogController.showMitmDialog ||
ConnectionSecurityDialogController.showTlsDialog ||
ConnectionSecurityDialogController.showPublicHttpDialog
}
) {
return@withContext
}
is ServerProbeResult.Supported -> {
if (warnPublicHttpIfNeeded(tentative) {
scope.launch {
when (probe) {
ServerProbeResult.Unsupported -> {
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strUnsupportedInstance)
}
}
ServerProbeResult.Timeout -> {
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strSnackbarTimeout)
}
}
ServerProbeResult.Unreachable -> {
withContext(Dispatchers.Main) {
snackbarHostState.showSnackbar(strSnackbarApiFail)
}
}
is ServerProbeResult.Supported -> {
val warned = withContext(Dispatchers.Main) {
warnPublicHttpIfNeeded(tentative) {
scope.launch(Dispatchers.Default) {
applySupportedServerProbe(probe, tentative)
}
}
}
if (!warned) {
applySupportedServerProbe(probe, tentative)
}
}) {
return@launch
}
applySupportedServerProbe(probe, tentative)
}
}
} finally {
@@ -499,7 +521,7 @@ fun ServerConfigScreen(
.background(listBackground)
.hazeSource(actionHazeState)
.onGloballyPositioned { listViewportBounds = it.boundsInWindow() },
contentPadding = PaddingValues(bottom = bottomInsetPadding),
contentPadding = PaddingValues(bottom = bottomInsetPadding + 16.dp),
verticalArrangement = remember {
LastAnchoredBottomArrangement(space = 4.dp)
},
@@ -5,12 +5,14 @@ import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.lazy.LazyItemScope
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.ErrorOutline
import androidx.compose.material.icons.rounded.FlashOn
import androidx.compose.material.icons.rounded.Key
import androidx.compose.material.icons.rounded.Link
import androidx.compose.material.icons.rounded.Lock
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
@@ -21,31 +23,31 @@ import ru.fromchat.api.instance.ServerProbeResult
import ru.fromchat.server_config_info_encryption
import ru.fromchat.server_config_info_instance
import ru.fromchat.server_config_info_protocol
import ru.fromchat.server_config_snackbar_timeout
import ru.fromchat.server_config_unsupported_no_instance_id
import ru.fromchat.server_config_info_instance
import ru.fromchat.server_config_info_protocol
import ru.fromchat.server_config_probe_pending
import ru.fromchat.server_config_snackbar_api_fail
import ru.fromchat.server_config_snackbar_timeout
import ru.fromchat.server_config_unsupported_no_instance_id
import ru.fromchat.ui.components.Text
import ru.fromchat.ui.components.ExpressiveListItem
import ru.fromchat.ui.components.ExpressiveTints
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
import ru.fromchat.utils.components.Category
private val serverProbeCategoryMargin = PaddingValues(
start = SettingsStepHorizontalPadding,
end = SettingsStepHorizontalPadding,
top = 0.dp,
bottom = 12.dp,
)
@Composable
fun ServerInstanceProbeCard(
fun LazyItemScope.ServerInstanceProbeCard(
probe: ServerProbeResult?,
probing: Boolean,
modifier: Modifier = Modifier,
) {
val scheme = MaterialTheme.colorScheme
Surface(
modifier = modifier
.fillMaxWidth()
.padding(horizontal = SettingsStepHorizontalPadding),
shape = MaterialTheme.shapes.large,
color = scheme.surfaceContainerHigh,
tonalElevation = 2.dp,
Category(
modifier = modifier,
margin = serverProbeCategoryMargin,
) {
AnimatedContent(
targetState = probe to probing,
@@ -54,65 +56,58 @@ fun ServerInstanceProbeCard(
},
label = "serverProbeInfo",
) { (result, busy) ->
Column(
Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 14.dp),
verticalArrangement = Arrangement.spacedBy(6.dp),
) {
when {
busy && result == null -> {
Text(
stringResource(Res.string.server_config_probe_pending),
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
)
}
result is ServerProbeResult.Supported -> {
InfoRow(
stringResource(Res.string.server_config_info_instance),
result.instanceId,
)
InfoRow(
stringResource(Res.string.server_config_info_protocol),
result.protocol,
)
val modeLabel = InstanceEncryptionMode.fromWire(result.encryptionMode)
?.let { InstanceEncryptionMode.displayLabel(it) }
?: result.encryptionMode
InfoRow(
stringResource(Res.string.server_config_info_encryption),
modeLabel,
)
}
result is ServerProbeResult.Timeout -> {
Text(
stringResource(Res.string.server_config_snackbar_timeout),
color = scheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
result is ServerProbeResult.Unreachable -> {
Text(
stringResource(Res.string.server_config_snackbar_api_fail),
color = scheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
result is ServerProbeResult.Unsupported -> {
Text(
stringResource(Res.string.server_config_unsupported_no_instance_id),
color = scheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
else -> {
Text(
stringResource(Res.string.server_config_probe_pending),
style = MaterialTheme.typography.bodyMedium,
color = scheme.onSurfaceVariant,
)
}
when {
busy && result == null -> {
ExpressiveListItem(
icon = Icons.Rounded.FlashOn,
iconPreset = ExpressiveTints.Cyan,
headline = stringResource(Res.string.server_config_probe_pending),
supportingText = null,
enabled = false,
)
}
result is ServerProbeResult.Supported -> {
ExpressiveListItem(
icon = Icons.Rounded.Key,
iconPreset = ExpressiveTints.Blue,
headline = stringResource(Res.string.server_config_info_instance),
supportingText = result.instanceId,
divider = true,
)
ExpressiveListItem(
icon = Icons.Rounded.Link,
iconPreset = ExpressiveTints.Cyan,
headline = stringResource(Res.string.server_config_info_protocol),
supportingText = result.protocol,
divider = true,
)
val modeLabel = InstanceEncryptionMode.fromWire(result.encryptionMode)
?.let { InstanceEncryptionMode.displayLabel(it) }
?: result.encryptionMode
ExpressiveListItem(
icon = Icons.Rounded.Lock,
iconPreset = ExpressiveTints.Green,
headline = stringResource(Res.string.server_config_info_encryption),
supportingText = modeLabel,
)
}
result is ServerProbeResult.Timeout -> {
ProbeErrorListItem(stringResource(Res.string.server_config_snackbar_timeout))
}
result is ServerProbeResult.Unreachable -> {
ProbeErrorListItem(stringResource(Res.string.server_config_snackbar_api_fail))
}
result is ServerProbeResult.Unsupported -> {
ProbeErrorListItem(stringResource(Res.string.server_config_unsupported_no_instance_id))
}
else -> {
ExpressiveListItem(
icon = Icons.Rounded.FlashOn,
iconPreset = ExpressiveTints.Cyan,
headline = stringResource(Res.string.server_config_probe_pending),
supportingText = null,
enabled = false,
)
}
}
}
@@ -120,18 +115,11 @@ fun ServerInstanceProbeCard(
}
@Composable
private fun InfoRow(label: String, value: String) {
val scheme = MaterialTheme.colorScheme
Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
Text(
label,
style = MaterialTheme.typography.labelMedium,
color = scheme.onSurfaceVariant,
)
Text(
value,
style = MaterialTheme.typography.bodyLarge,
color = scheme.onSurface,
)
}
private fun ProbeErrorListItem(message: String) {
ExpressiveListItem(
icon = Icons.Rounded.ErrorOutline,
iconPreset = ExpressiveTints.Rose,
headline = message,
supportingText = null,
)
}