mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-11-15 18:34:54 +03:00
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:
@@ -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>
|
||||
|
||||
+7
-2
@@ -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 }
|
||||
|
||||
+57
-55
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+133
@@ -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
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+79
-57
@@ -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)
|
||||
},
|
||||
|
||||
+81
-93
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user