From ca3d9fedc478657ec3b0dfd138deffd25bb26879 Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Sun, 8 Nov 2026 20:32:12 +0300 Subject: [PATCH] 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 --- .../kotlin/ru/fromchat/ui/SplashHandoff.kt | 16 +- .../main/kotlin/ru/fromchat/desktop/Main.kt | 4 +- .../composeResources/values-ru/strings.xml | 7 +- .../composeResources/values/strings.xml | 7 +- .../api/instance/EncryptionModeNoticeStore.kt | 9 +- .../api/instance/InstanceEncryptionStore.kt | 1 + .../api/instance/ServerConfigProbe.kt | 14 +- .../incognito/IncognitoOverlaySession.kt | 50 ++++ .../commonMain/kotlin/ru/fromchat/ui/App.kt | 23 +- .../kotlin/ru/fromchat/ui/auth/AuthLayout.kt | 64 ++--- .../kotlin/ru/fromchat/ui/auth/AuthScreen.kt | 21 +- .../ru/fromchat/ui/auth/PasswordStep.kt | 30 +- .../ru/fromchat/ui/auth/UsernameStep.kt | 20 +- .../ui/chat/chrome/ChatInitialLoadOverlay.kt | 70 +++++ .../ru/fromchat/ui/chat/chrome/ChatScreen.kt | 27 +- .../ui/chat/media/ImageFullscreenPreview.kt | 112 ++++---- .../fromchat/ui/chat/message/MessageItem.kt | 89 +++++- .../ui/components/HazeActionButton.kt | 2 +- .../ui/components/SpringDropdownMenu.kt | 263 ++++++++++++++++++ .../components/SpringOverflowMenuOverlay.kt | 133 +++++++++ .../ru/fromchat/ui/main/chats/ChatsTab.kt | 87 +++--- .../ui/main/settings/logs/LogsScreen.kt | 100 +++---- .../settings/server/ServerConfigScreen.kt | 136 +++++---- .../server/ServerInstanceProbeCard.kt | 174 ++++++------ 24 files changed, 1061 insertions(+), 398 deletions(-) create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/incognito/IncognitoOverlaySession.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatInitialLoadOverlay.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringDropdownMenu.kt create mode 100644 app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringOverflowMenuOverlay.kt diff --git a/app/android/src/main/kotlin/ru/fromchat/ui/SplashHandoff.kt b/app/android/src/main/kotlin/ru/fromchat/ui/SplashHandoff.kt index c3cf797..54f6834 100644 --- a/app/android/src/main/kotlin/ru/fromchat/ui/SplashHandoff.kt +++ b/app/android/src/main/kotlin/ru/fromchat/ui/SplashHandoff.kt @@ -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 diff --git a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt index 4359d2d..bd91bdc 100644 --- a/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt +++ b/app/desktop/src/main/kotlin/ru/fromchat/desktop/Main.kt @@ -622,7 +622,9 @@ fun main(args: Array) { } fun openAbout() { - showMainWindow() + if (!windowVisible) { + showMainWindow() + } DesktopMenuCommands.emit(DesktopMenuCommand.OpenAbout) } diff --git a/app/shared/src/commonMain/composeResources/values-ru/strings.xml b/app/shared/src/commonMain/composeResources/values-ru/strings.xml index fb84ffb..e09cc0d 100644 --- a/app/shared/src/commonMain/composeResources/values-ru/strings.xml +++ b/app/shared/src/commonMain/composeResources/values-ru/strings.xml @@ -73,12 +73,12 @@ Скрыть пароль %1$d/%2$d Введите имя пользователя - Режим инкогнито + Инкогнито Вход остаётся приватным. Данные сессии шифруются и удаляются при выходе. Для api.fromchat.ru действуют дополнительные проверки HTTPS. Инкогнито включён для этой сессии. Режим инкогнито После закрытия ваша сессия и кэш будут удалены, а тот, кто воспользуется устройством после вас, вряд ли сможет что-то восстановить. - Режим инкогнито + Инкогнито Режим инкогнито Обычный вход Сейчас включён инкогнито. После закрытия приложения сессия и кэш будут удалены. @@ -337,6 +337,7 @@ Сервер не предоставляет корректный ID экземпляра. Подключение невозможно. Сервер не ответил вовремя. Повторите попытку. Проверка… + Сервер Экземпляр Протокол Шифрование @@ -354,7 +355,7 @@ Будут удалены офлайн-сообщения и загрузки для всех экземпляров сервера на этом устройстве. Неотправленные сообщения из очереди будут потеряны. Локальные данные очищены Сохранить и продолжить - Сменить сервер + Сервер Подключиться к альтернативному серверу FromChat и выйти из аккаунта. Выйти Material You diff --git a/app/shared/src/commonMain/composeResources/values/strings.xml b/app/shared/src/commonMain/composeResources/values/strings.xml index 951894e..3560c4d 100644 --- a/app/shared/src/commonMain/composeResources/values/strings.xml +++ b/app/shared/src/commonMain/composeResources/values/strings.xml @@ -82,12 +82,12 @@ Hide password %1$d/%2$d Enter your username - Incognito mode + Incognito Keeps this sign-in private. Session data is encrypted and deleted when you quit. Extra HTTPS checks apply for api.fromchat.ru. Incognito is on for this session. Incognito login 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. - Incognito login + Incognito Incognito login Standard login Incognito is on. Closing the app clears this session and its cache. @@ -365,6 +365,7 @@ This server does not provide a valid instance ID. Cannot connect. Server did not respond in time. Try again. Checking… + Server Instance Protocol Encryption @@ -385,7 +386,7 @@ Save and continue - Change server + Server Connect to an alternative FromChat server and sign out. Log out Material You diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/EncryptionModeNoticeStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/EncryptionModeNoticeStore.kt index f2252af..f2716d7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/EncryptionModeNoticeStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/EncryptionModeNoticeStore.kt @@ -66,7 +66,10 @@ object EncryptionModeNoticeStore { val byId = LinkedHashMap() 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() 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()) } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceEncryptionStore.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceEncryptionStore.kt index 53f3591..86aaa66 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceEncryptionStore.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/InstanceEncryptionStore.kt @@ -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 diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt index 8f42934..ff06100 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/api/instance/ServerConfigProbe.kt @@ -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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/incognito/IncognitoOverlaySession.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/incognito/IncognitoOverlaySession.kt new file mode 100644 index 0000000..bc5bcd5 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/incognito/IncognitoOverlaySession.kt @@ -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 + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt index 8e6aa0c..a943b31 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/App.kt @@ -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 = diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt index 4cc6ebc..0f5927e 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthLayout.kt @@ -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 = { diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt index 05dba7c..765bcca 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/AuthScreen.kt @@ -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( diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt index c3b7570..1f51e90 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/PasswordStep.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt index 814bfff..45e4b25 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/auth/UsernameStep.kt @@ -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, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatInitialLoadOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatInitialLoadOverlay.kt new file mode 100644 index 0000000..529af96 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatInitialLoadOverlay.kt @@ -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() + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatScreen.kt index 226b3eb..462470d 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/chrome/ChatScreen.kt @@ -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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/media/ImageFullscreenPreview.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/media/ImageFullscreenPreview.kt index f95b147..dc3d219 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/media/ImageFullscreenPreview.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/media/ImageFullscreenPreview.kt @@ -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 - }, - ) - } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/message/MessageItem.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/message/MessageItem.kt index ff90745..d505423 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/message/MessageItem.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/chat/message/MessageItem.kt @@ -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( + 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 } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt index b6ead63..22b9cc7 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/HazeActionButton.kt @@ -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() } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringDropdownMenu.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringDropdownMenu.kt new file mode 100644 index 0000000..09ad223 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringDropdownMenu.kt @@ -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( + 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() + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringOverflowMenuOverlay.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringOverflowMenuOverlay.kt new file mode 100644 index 0000000..c4a1472 --- /dev/null +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/components/SpringOverflowMenuOverlay.kt @@ -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(null) + private set + + fun publish(slot: SpringOverflowMenuOverlaySlot?) { + this.slot = slot + } + + fun clear() { + slot = null + } +} + +val LocalSpringOverflowMenuOverlayController = + staticCompositionLocalOf { 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, + ) + } + } + } +} diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt index 813a48d..4e8b4cf 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/chats/ChatsTab.kt @@ -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 }, ) diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/logs/LogsScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/logs/LogsScreen.kt index 54bc119..1d7cc37 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/logs/LogsScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/logs/LogsScreen.kt @@ -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 + } + }, + ) + } } } } diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt index 4b22f64..78b02f9 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerConfigScreen.kt @@ -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) }, diff --git a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerInstanceProbeCard.kt b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerInstanceProbeCard.kt index 586aa1a..eb83a0c 100644 --- a/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerInstanceProbeCard.kt +++ b/app/shared/src/commonMain/kotlin/ru/fromchat/ui/main/settings/server/ServerInstanceProbeCard.kt @@ -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, + ) }