mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-22 19:15:05 +03:00
@@ -262,6 +262,12 @@ fun main(args: Array<String>) {
|
|||||||
// Treat TrayIcon images as NSImage templates so the menu bar tints them for light/dark.
|
// Treat TrayIcon images as NSImage templates so the menu bar tints them for light/dark.
|
||||||
// Must be set before CTrayIcon loads (Compose Tray / SystemTray).
|
// Must be set before CTrayIcon loads (Compose Tray / SystemTray).
|
||||||
System.setProperty("apple.awt.enableTemplateImages", "true")
|
System.setProperty("apple.awt.enableTemplateImages", "true")
|
||||||
|
val appearanceName = when (runCatching { Settings.theme }.getOrDefault(Theme.AsSystem)) {
|
||||||
|
Theme.Dark -> "NSAppearanceNameDarkAqua"
|
||||||
|
Theme.Light -> "NSAppearanceNameAqua"
|
||||||
|
Theme.AsSystem -> "system"
|
||||||
|
}
|
||||||
|
System.setProperty("apple.awt.application.appearance", appearanceName)
|
||||||
}
|
}
|
||||||
if (!DesktopSingleInstance.acquireOrForward(args)) return
|
if (!DesktopSingleInstance.acquireOrForward(args)) return
|
||||||
DesktopProtocolRegistration.register()
|
DesktopProtocolRegistration.register()
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
jvmMain.dependencies {
|
jvmMain.dependencies {
|
||||||
|
implementation(compose.desktop.currentOs)
|
||||||
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
implementation(libs.jetbrains.kotlinx.io.bytestring)
|
||||||
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
implementation(libs.jetbrains.kotlinx.coroutines.core)
|
||||||
implementation(libs.ktor.client.cio)
|
implementation(libs.ktor.client.cio)
|
||||||
|
|||||||
+3
-1
@@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties
|
|||||||
internal actual fun MessageContextMenuPopup(
|
internal actual fun MessageContextMenuPopup(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
positionProvider: PopupPositionProvider,
|
positionProvider: PopupPositionProvider,
|
||||||
|
reserveOvershoot: Boolean,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Popup(
|
Popup(
|
||||||
@@ -18,7 +19,8 @@ internal actual fun MessageContextMenuPopup(
|
|||||||
focusable = true,
|
focusable = true,
|
||||||
dismissOnBackPress = true,
|
dismissOnBackPress = true,
|
||||||
dismissOnClickOutside = true,
|
dismissOnClickOutside = true,
|
||||||
clippingEnabled = true,
|
// Let the spring draw past the laid-out bounds while [reserveOvershoot] is true.
|
||||||
|
clippingEnabled = !reserveOvershoot,
|
||||||
),
|
),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
+6
@@ -12,6 +12,7 @@ import androidx.compose.runtime.remember
|
|||||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
import androidx.compose.ui.draganddrop.mimeTypes
|
import androidx.compose.ui.draganddrop.mimeTypes
|
||||||
import androidx.compose.ui.draganddrop.toAndroidDragEvent
|
import androidx.compose.ui.draganddrop.toAndroidDragEvent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
import androidx.compose.ui.platform.LocalContext
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.core.app.ActivityCompat
|
import androidx.core.app.ActivityCompat
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
@@ -34,6 +35,11 @@ actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHos
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? {
|
||||||
|
val androidEvent = event.toAndroidDragEvent()
|
||||||
|
return Offset(androidEvent.x, androidEvent.y)
|
||||||
|
}
|
||||||
|
|
||||||
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
|
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
|
||||||
val mimeTypes = event.mimeTypes()
|
val mimeTypes = event.mimeTypes()
|
||||||
if (mimeTypes.any { it == ClipDescription.MIMETYPE_TEXT_URILIST }) return true
|
if (mimeTypes.any { it == ClipDescription.MIMETYPE_TEXT_URILIST }) return true
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
|||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
import ru.fromchat.ui.components.ScreenSurface
|
import ru.fromchat.ui.components.ScreenSurface
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
|
|
||||||
private data class PendingDocument(
|
private data class PendingDocument(
|
||||||
val parsed: ParsedDocument,
|
val parsed: ParsedDocument,
|
||||||
@@ -119,6 +120,7 @@ fun DocumentScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
val topBarHazeStyle = rememberChatSurfaceContainerHazeStyle()
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
|
windowInsets = WindowInsets.extraStatusBars,
|
||||||
title = {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = when (type) {
|
text = when (type) {
|
||||||
|
|||||||
@@ -84,6 +84,9 @@ import ru.fromchat.ui.auth.captcha.SmartCaptchaNav
|
|||||||
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
import ru.fromchat.ui.auth.captcha.SmartCaptchaScreen
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||||
|
import ru.fromchat.ui.chat.ChatFullscreenImageController
|
||||||
|
import ru.fromchat.ui.chat.ChatFullscreenImageHost
|
||||||
|
import ru.fromchat.ui.chat.LocalChatFullscreenImageController
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
|
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
|
||||||
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
import ru.fromchat.ui.chat.panels.publicchat.PublicChatNav
|
||||||
@@ -471,13 +474,15 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val fullscreenImageController = remember { ChatFullscreenImageController() }
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalNavController provides navController,
|
LocalNavController provides navController,
|
||||||
LocalDesktopChatsNavController provides
|
LocalDesktopChatsNavController provides
|
||||||
if (isDesktopListDetail) chatsDetailNavController else null,
|
if (isDesktopListDetail) chatsDetailNavController else null,
|
||||||
LocalDesktopSettingsNavController provides
|
LocalDesktopSettingsNavController provides
|
||||||
if (isDesktopListDetail) settingsDetailNavController else null,
|
if (isDesktopListDetail) settingsDetailNavController else null,
|
||||||
LocalSystemBarsVisibility provides rememberSystemBarsController()
|
LocalSystemBarsVisibility provides rememberSystemBarsController(),
|
||||||
|
LocalChatFullscreenImageController provides fullscreenImageController,
|
||||||
) {
|
) {
|
||||||
if (startDestination != null) {
|
if (startDestination != null) {
|
||||||
val currentEntry by navController.currentBackStackEntryAsState()
|
val currentEntry by navController.currentBackStackEntryAsState()
|
||||||
@@ -673,9 +678,6 @@ fun App(
|
|||||||
AppNavHost(Modifier.fillMaxSize())
|
AppNavHost(Modifier.fillMaxSize())
|
||||||
|
|
||||||
if (showConversationListDetail) {
|
if (showConversationListDetail) {
|
||||||
val detailEdgeToEdge =
|
|
||||||
pendingMainTab == MAIN_PAGE_CHATS ||
|
|
||||||
pendingMainTab == MAIN_PAGE_CONTACTS
|
|
||||||
val listPaneHazeState = rememberHazeState()
|
val listPaneHazeState = rememberHazeState()
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalPaneHazeState provides listPaneHazeState,
|
LocalPaneHazeState provides listPaneHazeState,
|
||||||
@@ -683,7 +685,7 @@ fun App(
|
|||||||
) {
|
) {
|
||||||
ConversationListDetailShell(
|
ConversationListDetailShell(
|
||||||
detailInPanel = false,
|
detailInPanel = false,
|
||||||
detailEdgeToEdge = detailEdgeToEdge,
|
detailEdgeToEdge = true,
|
||||||
listPane = {
|
listPane = {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
@@ -732,6 +734,7 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ChatFullscreenImageHost(Modifier.fillMaxSize())
|
||||||
CallOverlay(Modifier.fillMaxSize())
|
CallOverlay(Modifier.fillMaxSize())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ import androidx.compose.ui.graphics.ImageBitmap
|
|||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
import androidx.compose.ui.semantics.contentDescription
|
import androidx.compose.ui.semantics.contentDescription
|
||||||
import androidx.compose.ui.semantics.semantics
|
import androidx.compose.ui.semantics.semantics
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
@@ -239,15 +239,8 @@ fun AttachmentPreview(
|
|||||||
.then(
|
.then(
|
||||||
if (onImageBounds != null && showImageTile) {
|
if (onImageBounds != null && showImageTile) {
|
||||||
Modifier.onGloballyPositioned { coords ->
|
Modifier.onGloballyPositioned { coords ->
|
||||||
val pos = coords.positionInRoot()
|
|
||||||
val size = coords.size
|
|
||||||
onImageBounds(
|
onImageBounds(
|
||||||
Rect(
|
coords.boundsInWindow()
|
||||||
pos.x,
|
|
||||||
pos.y,
|
|
||||||
pos.x + size.width.toFloat(),
|
|
||||||
pos.y + size.height.toFloat()
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -117,12 +117,12 @@ import ru.fromchat.message_placeholder
|
|||||||
import ru.fromchat.message_replying_to
|
import ru.fromchat.message_replying_to
|
||||||
import ru.fromchat.suspend_chat_banner_message
|
import ru.fromchat.suspend_chat_banner_message
|
||||||
import ru.fromchat.ui.chat.utils.SelectedAttachment
|
import ru.fromchat.ui.chat.utils.SelectedAttachment
|
||||||
import ru.fromchat.ui.chat.utils.AttachmentDragSession
|
|
||||||
import ru.fromchat.ui.chat.utils.TypingHandler
|
import ru.fromchat.ui.chat.utils.TypingHandler
|
||||||
import ru.fromchat.ui.chat.utils.AttachmentDropBridge
|
import ru.fromchat.ui.chat.utils.AttachmentDropBridge
|
||||||
import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter
|
import ru.fromchat.ui.chat.utils.GlobalAttachmentDropRouter
|
||||||
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
|
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
|
||||||
import ru.fromchat.ui.chat.utils.getFilenameFromUri
|
import ru.fromchat.ui.chat.utils.getFilenameFromUri
|
||||||
|
import ru.fromchat.ui.chat.utils.isDropHighlightActive
|
||||||
import ru.fromchat.ui.chat.utils.rememberFilePicker
|
import ru.fromchat.ui.chat.utils.rememberFilePicker
|
||||||
import ru.fromchat.ui.chat.utils.rememberImagePicker
|
import ru.fromchat.ui.chat.utils.rememberImagePicker
|
||||||
import ru.fromchat.ui.chat.utils.urisToSelectedAttachments
|
import ru.fromchat.ui.chat.utils.urisToSelectedAttachments
|
||||||
@@ -327,8 +327,7 @@ fun ChatInput(
|
|||||||
val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f)
|
val dropScrimColor = lerp(MaterialTheme.colorScheme.primary, Color.Black, 0.62f)
|
||||||
val attachmentDropEnabled = supportsAttachments && !isReadOnly
|
val attachmentDropEnabled = supportsAttachments && !isReadOnly
|
||||||
val dropHighlightActive =
|
val dropHighlightActive =
|
||||||
attachmentDropEnabled &&
|
attachmentDropEnabled && attachmentDropBridge.isDropHighlightActive()
|
||||||
(attachmentDropBridge.dropHighlightActive || AttachmentDragSession.isActive)
|
|
||||||
val dropAnimMs = 220
|
val dropAnimMs = 220
|
||||||
val dropScale by animateFloatAsState(
|
val dropScale by animateFloatAsState(
|
||||||
targetValue = if (dropHighlightActive) 0.96f else 1f,
|
targetValue = if (dropHighlightActive) 0.96f else 1f,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ import androidx.compose.material3.Icon
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.SideEffect
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
@@ -1532,54 +1533,108 @@ fun ChatScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
expandedImage?.let { (msg, idx) ->
|
val fullscreenHost = LocalChatFullscreenImageController.current
|
||||||
val key = imageAttachmentKey(msg, idx)
|
DisposableEffect(fullscreenHost) {
|
||||||
ImageFullscreenPreview(
|
onDispose { fullscreenHost?.request = null }
|
||||||
|
}
|
||||||
|
val expandedImageSnapshot = expandedImage
|
||||||
|
SideEffect {
|
||||||
|
val host = fullscreenHost ?: return@SideEffect
|
||||||
|
val pair = expandedImageSnapshot
|
||||||
|
if (pair == null) {
|
||||||
|
if (host.request != null) host.request = null
|
||||||
|
return@SideEffect
|
||||||
|
}
|
||||||
|
val (msg, idx) = pair
|
||||||
|
host.onDismiss = {
|
||||||
|
isImageClosing = false
|
||||||
|
expandedImage = null
|
||||||
|
}
|
||||||
|
host.onClosingChange = { closing -> isImageClosing = closing }
|
||||||
|
host.onReply = { m ->
|
||||||
|
replyTo = m
|
||||||
|
if (editingMessage != null) {
|
||||||
|
editingMessage = null
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
isImageClosing = false
|
||||||
|
expandedImage = null
|
||||||
|
}
|
||||||
|
host.onDelete = { m ->
|
||||||
|
scope.launch { panel.handleDeleteMessage(m.id) }
|
||||||
|
}
|
||||||
|
host.onSave = save@{ savedMsg, fileIndex ->
|
||||||
|
if (!isMessageImageFullyLoaded(savedMsg, fileIndex)) return@save
|
||||||
|
val file = savedMsg.files?.getOrNull(fileIndex) ?: return@save
|
||||||
|
resolveImageSourceUri(savedMsg, fileIndex)?.let { source ->
|
||||||
|
saveMessageImage(
|
||||||
|
SavableMessageImage(
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
sourceUri = source,
|
||||||
|
filename = file.name,
|
||||||
|
mimeType = mimeTypeForImageFilename(file.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val published = ChatFullscreenImageRequest(
|
||||||
message = msg,
|
message = msg,
|
||||||
fileIndex = idx,
|
fileIndex = idx,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onDismiss = {
|
thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)],
|
||||||
isImageClosing = false
|
|
||||||
expandedImage = null
|
|
||||||
},
|
|
||||||
onClosingChange = { isImageClosing = it },
|
|
||||||
onReply = { m ->
|
|
||||||
replyTo = m
|
|
||||||
if (editingMessage != null) {
|
|
||||||
editingMessage = null
|
|
||||||
inputText = ""
|
|
||||||
}
|
|
||||||
isImageClosing = false
|
|
||||||
expandedImage = null
|
|
||||||
},
|
|
||||||
onDelete = { m ->
|
|
||||||
scope.launch {
|
|
||||||
panel.handleDeleteMessage(m.id)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSave = { msg, fileIndex ->
|
|
||||||
if (!isMessageImageFullyLoaded(msg, fileIndex)) return@ImageFullscreenPreview
|
|
||||||
val file = msg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview
|
|
||||||
resolveImageSourceUri(msg, fileIndex)?.let { source ->
|
|
||||||
saveMessageImage(
|
|
||||||
SavableMessageImage(
|
|
||||||
fileIndex = fileIndex,
|
|
||||||
sourceUri = source,
|
|
||||||
filename = file.name,
|
|
||||||
mimeType = mimeTypeForImageFilename(file.name),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
sharedTransitionScope = null,
|
|
||||||
animatedVisibilityScope = null,
|
|
||||||
sharedImageKey = null,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
thumbnailBounds = imageThumbBounds[key]
|
|
||||||
)
|
)
|
||||||
|
if (host.request != published) host.request = published
|
||||||
|
}
|
||||||
|
if (fullscreenHost == null) {
|
||||||
|
expandedImage?.let { (msg, idx) ->
|
||||||
|
ImageFullscreenPreview(
|
||||||
|
message = msg,
|
||||||
|
fileIndex = idx,
|
||||||
|
currentUserId = currentUserId,
|
||||||
|
onDismiss = {
|
||||||
|
isImageClosing = false
|
||||||
|
expandedImage = null
|
||||||
|
},
|
||||||
|
onClosingChange = { isImageClosing = it },
|
||||||
|
onReply = { m ->
|
||||||
|
replyTo = m
|
||||||
|
if (editingMessage != null) {
|
||||||
|
editingMessage = null
|
||||||
|
inputText = ""
|
||||||
|
}
|
||||||
|
isImageClosing = false
|
||||||
|
expandedImage = null
|
||||||
|
},
|
||||||
|
onDelete = { m ->
|
||||||
|
scope.launch {
|
||||||
|
panel.handleDeleteMessage(m.id)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSave = { savedMsg, fileIndex ->
|
||||||
|
if (!isMessageImageFullyLoaded(savedMsg, fileIndex)) {
|
||||||
|
return@ImageFullscreenPreview
|
||||||
|
}
|
||||||
|
val file = savedMsg.files?.getOrNull(fileIndex) ?: return@ImageFullscreenPreview
|
||||||
|
resolveImageSourceUri(savedMsg, fileIndex)?.let { source ->
|
||||||
|
saveMessageImage(
|
||||||
|
SavableMessageImage(
|
||||||
|
fileIndex = fileIndex,
|
||||||
|
sourceUri = source,
|
||||||
|
filename = file.name,
|
||||||
|
mimeType = mimeTypeForImageFilename(file.name),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
sharedTransitionScope = null,
|
||||||
|
animatedVisibilityScope = null,
|
||||||
|
sharedImageKey = null,
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
thumbnailBounds = imageThumbBounds[imageAttachmentKey(msg, idx)],
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -101,6 +102,46 @@ private data class InitialTransform(
|
|||||||
val bgAlpha: Float
|
val bgAlpha: Float
|
||||||
)
|
)
|
||||||
|
|
||||||
|
class ChatFullscreenImageController {
|
||||||
|
var request by mutableStateOf<ChatFullscreenImageRequest?>(null)
|
||||||
|
var onDismiss: () -> Unit = {}
|
||||||
|
var onClosingChange: (Boolean) -> Unit = {}
|
||||||
|
var onReply: (Message) -> Unit = {}
|
||||||
|
var onDelete: (Message) -> Unit = {}
|
||||||
|
var onSave: (Message, Int) -> Unit = { _, _ -> }
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ChatFullscreenImageRequest(
|
||||||
|
val message: Message,
|
||||||
|
val fileIndex: Int,
|
||||||
|
val currentUserId: Int?,
|
||||||
|
val thumbnailBounds: Rect?,
|
||||||
|
)
|
||||||
|
|
||||||
|
val LocalChatFullscreenImageController =
|
||||||
|
staticCompositionLocalOf<ChatFullscreenImageController?> { null }
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChatFullscreenImageHost(modifier: Modifier = Modifier) {
|
||||||
|
val controller = LocalChatFullscreenImageController.current ?: return
|
||||||
|
val request = controller.request ?: return
|
||||||
|
ImageFullscreenPreview(
|
||||||
|
message = request.message,
|
||||||
|
fileIndex = request.fileIndex,
|
||||||
|
currentUserId = request.currentUserId,
|
||||||
|
onDismiss = controller.onDismiss,
|
||||||
|
onClosingChange = controller.onClosingChange,
|
||||||
|
onReply = controller.onReply,
|
||||||
|
onDelete = controller.onDelete,
|
||||||
|
onSave = controller.onSave,
|
||||||
|
sharedTransitionScope = null,
|
||||||
|
animatedVisibilityScope = null,
|
||||||
|
sharedImageKey = null,
|
||||||
|
modifier = modifier.fillMaxSize(),
|
||||||
|
thumbnailBounds = request.thumbnailBounds,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun ImageFullscreenPreview(
|
fun ImageFullscreenPreview(
|
||||||
message: Message,
|
message: Message,
|
||||||
|
|||||||
@@ -17,8 +17,10 @@ import androidx.compose.foundation.layout.IntrinsicSize
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.requiredWidth
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
@@ -36,6 +38,7 @@ import androidx.compose.runtime.Composable
|
|||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
@@ -75,6 +78,7 @@ import ru.fromchat.ui.components.Text
|
|||||||
internal expect fun MessageContextMenuPopup(
|
internal expect fun MessageContextMenuPopup(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
positionProvider: PopupPositionProvider,
|
positionProvider: PopupPositionProvider,
|
||||||
|
reserveOvershoot: Boolean = false,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -164,12 +168,15 @@ fun MessageContextMenu(
|
|||||||
}
|
}
|
||||||
val animationProgress = remember { mutableFloatStateOf(0f) }
|
val animationProgress = remember { mutableFloatStateOf(0f) }
|
||||||
var enterLaidOut by remember(state.message) { mutableStateOf(false) }
|
var enterLaidOut by remember(state.message) { mutableStateOf(false) }
|
||||||
|
var reserveOvershoot by remember(state.message) { mutableStateOf(true) }
|
||||||
var frozenOrigin by remember(state.message) {
|
var frozenOrigin by remember(state.message) {
|
||||||
mutableStateOf(TransformOrigin(0f, 0f))
|
mutableStateOf(TransformOrigin(0f, 0f))
|
||||||
}
|
}
|
||||||
|
var lockedMenuWidthPx by remember(state.message) { mutableIntStateOf(0) }
|
||||||
|
|
||||||
LaunchedEffect(state.isOpen) {
|
LaunchedEffect(state.isOpen) {
|
||||||
if (!state.isOpen) {
|
if (!state.isOpen) {
|
||||||
|
reserveOvershoot = true
|
||||||
animate(
|
animate(
|
||||||
initialValue = animationProgress.floatValue,
|
initialValue = animationProgress.floatValue,
|
||||||
targetValue = 0f,
|
targetValue = 0f,
|
||||||
@@ -188,9 +195,11 @@ fun MessageContextMenu(
|
|||||||
shouldShowPopup = true
|
shouldShowPopup = true
|
||||||
if (!enterLaidOut) {
|
if (!enterLaidOut) {
|
||||||
animationProgress.floatValue = 0f
|
animationProgress.floatValue = 0f
|
||||||
|
reserveOvershoot = true
|
||||||
return@LaunchedEffect
|
return@LaunchedEffect
|
||||||
}
|
}
|
||||||
animationProgress.floatValue = 0f
|
animationProgress.floatValue = 0f
|
||||||
|
reserveOvershoot = true
|
||||||
animate(
|
animate(
|
||||||
initialValue = 0f,
|
initialValue = 0f,
|
||||||
targetValue = 1f,
|
targetValue = 1f,
|
||||||
@@ -198,6 +207,7 @@ fun MessageContextMenu(
|
|||||||
) { value, _ ->
|
) { value, _ ->
|
||||||
animationProgress.floatValue = value
|
animationProgress.floatValue = value
|
||||||
}
|
}
|
||||||
|
reserveOvershoot = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,10 +249,11 @@ fun MessageContextMenu(
|
|||||||
// [state.position] is window coordinates (see MessageItem localToWindow).
|
// [state.position] is window coordinates (see MessageItem localToWindow).
|
||||||
// Alignment+offset would add the popup parent’s window origin again (double offset
|
// Alignment+offset would add the popup parent’s window origin again (double offset
|
||||||
// in list–detail panes). Place with an absolute provider instead.
|
// in list–detail panes). Place with an absolute provider instead.
|
||||||
// Desktop uses a real OS window so the menu can sit outside the app frame.
|
// Desktop hosts this in a transparent OS window so the menu can leave the app frame.
|
||||||
MessageContextMenuPopup(
|
MessageContextMenuPopup(
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
positionProvider = positionProvider,
|
positionProvider = positionProvider,
|
||||||
|
reserveOvershoot = reserveOvershoot,
|
||||||
) {
|
) {
|
||||||
ContextMenuContent(
|
ContextMenuContent(
|
||||||
message = state.message,
|
message = state.message,
|
||||||
@@ -276,24 +287,35 @@ fun MessageContextMenu(
|
|||||||
onRetrySend(it)
|
onRetrySend(it)
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
modifier = modifier.onSizeChanged { size ->
|
modifier = modifier
|
||||||
if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged
|
.then(
|
||||||
val adjusted = clampContextMenuOffset(
|
if (lockedMenuWidthPx > 0) {
|
||||||
position = state.position,
|
Modifier.requiredWidth(with(density) { lockedMenuWidthPx.toDp() })
|
||||||
popupSize = size,
|
} else {
|
||||||
screenWidthPx = screenWidthPx,
|
Modifier
|
||||||
screenHeightPx = screenHeightPx,
|
},
|
||||||
paddingPx = paddingPx,
|
|
||||||
allowOutsideWindow = allowOutsideWindow,
|
|
||||||
)
|
)
|
||||||
frozenOrigin = TransformOrigin(
|
.onSizeChanged { size ->
|
||||||
pivotFractionX = ((state.position.x - adjusted.x).toFloat() / size.width)
|
if (size.width > 0 && size.width > lockedMenuWidthPx) {
|
||||||
.coerceIn(0f, 1f),
|
lockedMenuWidthPx = size.width
|
||||||
pivotFractionY = ((state.position.y - adjusted.y).toFloat() / size.height)
|
}
|
||||||
.coerceIn(0f, 1f),
|
if (size.width <= 0 || size.height <= 0 || enterLaidOut) return@onSizeChanged
|
||||||
)
|
val adjusted = clampContextMenuOffset(
|
||||||
enterLaidOut = true
|
position = state.position,
|
||||||
},
|
popupSize = size,
|
||||||
|
screenWidthPx = screenWidthPx,
|
||||||
|
screenHeightPx = screenHeightPx,
|
||||||
|
paddingPx = paddingPx,
|
||||||
|
allowOutsideWindow = allowOutsideWindow,
|
||||||
|
)
|
||||||
|
frozenOrigin = TransformOrigin(
|
||||||
|
pivotFractionX = ((state.position.x - adjusted.x).toFloat() / size.width)
|
||||||
|
.coerceIn(0f, 1f),
|
||||||
|
pivotFractionY = ((state.position.y - adjusted.y).toFloat() / size.height)
|
||||||
|
.coerceIn(0f, 1f),
|
||||||
|
)
|
||||||
|
enterLaidOut = true
|
||||||
|
},
|
||||||
animated = true,
|
animated = true,
|
||||||
scale = scale,
|
scale = scale,
|
||||||
alpha = alpha,
|
alpha = alpha,
|
||||||
@@ -437,6 +459,7 @@ internal fun ChatStyleContextMenuFrame(
|
|||||||
// invalidation does not rebuild the elevated layer (avoids first-hover blink).
|
// invalidation does not rebuild the elevated layer (avoids first-hover blink).
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
|
.wrapContentWidth(unbounded = true)
|
||||||
.width(IntrinsicSize.Max)
|
.width(IntrinsicSize.Max)
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
if (animated) {
|
if (animated) {
|
||||||
|
|||||||
@@ -14,10 +14,10 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.Stable
|
import androidx.compose.runtime.Stable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.composed
|
import androidx.compose.ui.composed
|
||||||
@@ -26,10 +26,14 @@ import androidx.compose.ui.draganddrop.DragAndDropTarget
|
|||||||
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
import androidx.compose.ui.draw.BlurredEdgeTreatment
|
||||||
import androidx.compose.ui.draw.blur
|
import androidx.compose.ui.draw.blur
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.Shape
|
import androidx.compose.ui.graphics.Shape
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
import androidx.compose.ui.graphics.lerp
|
import androidx.compose.ui.graphics.lerp
|
||||||
|
import androidx.compose.ui.layout.boundsInWindow
|
||||||
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import kotlin.time.Clock
|
import kotlin.time.Clock
|
||||||
@@ -43,9 +47,73 @@ import com.pr0gramm3r101.utils.conditional
|
|||||||
/** True while a file drag that this process accepted is still in progress. */
|
/** True while a file drag that this process accepted is still in progress. */
|
||||||
object AttachmentDragSession {
|
object AttachmentDragSession {
|
||||||
var isActive by mutableStateOf(false)
|
var isActive by mutableStateOf(false)
|
||||||
internal set
|
private set
|
||||||
|
|
||||||
|
internal fun begin() {
|
||||||
|
isActive = true
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clears drag-session UI. Safe to call from any drop / end path. */
|
||||||
|
fun end() {
|
||||||
|
isActive = false
|
||||||
|
AttachmentDropHighlight.clear()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exactly one drop target may show the blur/scrim at a time. Hover is resolved by
|
||||||
|
* hit-testing registered window bounds against the current pointer so nested AWT
|
||||||
|
* targets cannot leave a stale row blurred.
|
||||||
|
*/
|
||||||
|
object AttachmentDropHighlight {
|
||||||
|
var activeBridge by mutableStateOf<AttachmentDropBridge?>(null)
|
||||||
|
private set
|
||||||
|
|
||||||
|
private val boundsByBridge = mutableMapOf<AttachmentDropBridge, Rect>()
|
||||||
|
|
||||||
|
fun register(bridge: AttachmentDropBridge, bounds: Rect) {
|
||||||
|
boundsByBridge[bridge] = bounds
|
||||||
|
}
|
||||||
|
|
||||||
|
fun unregister(bridge: AttachmentDropBridge) {
|
||||||
|
boundsByBridge.remove(bridge)
|
||||||
|
if (activeBridge === bridge) activeBridge = null
|
||||||
|
}
|
||||||
|
|
||||||
|
fun syncFromPointer(windowPoint: Offset) {
|
||||||
|
activeBridge = hitTest(windowPoint)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
activeBridge = null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delivers to the currently hovered target. Returns true if handled. */
|
||||||
|
fun deliverToOwner(uris: List<String>): Boolean {
|
||||||
|
if (uris.isEmpty()) return false
|
||||||
|
val bridge = activeBridge ?: return false
|
||||||
|
val consumer = bridge.consumer ?: return false
|
||||||
|
consumer(uris)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun hitTest(windowPoint: Offset): AttachmentDropBridge? {
|
||||||
|
var best: AttachmentDropBridge? = null
|
||||||
|
var bestArea = Float.POSITIVE_INFINITY
|
||||||
|
boundsByBridge.forEach { (bridge, bounds) ->
|
||||||
|
if (!bounds.contains(windowPoint)) return@forEach
|
||||||
|
val area = bounds.width * bounds.height
|
||||||
|
if (area > 0f && area < bestArea) {
|
||||||
|
bestArea = area
|
||||||
|
best = bridge
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun dragPointerInWindow(event: DragAndDropEvent): Offset?
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Delivers drops from the always-mounted app-root target to the visible chat composer.
|
* Delivers drops from the always-mounted app-root target to the visible chat composer.
|
||||||
* Needed when a chat opens after drag start (Compose targets miss ACTION_DRAG_STARTED).
|
* Needed when a chat opens after drag start (Compose targets miss ACTION_DRAG_STARTED).
|
||||||
@@ -90,33 +158,19 @@ object PendingChatAttachmentDrops {
|
|||||||
class AttachmentDropBridge {
|
class AttachmentDropBridge {
|
||||||
internal var consumer: ((List<String>) -> Unit)? = null
|
internal var consumer: ((List<String>) -> Unit)? = null
|
||||||
|
|
||||||
private var dropHighlightDepth by mutableIntStateOf(0)
|
|
||||||
var dropHighlightActive by mutableStateOf(false)
|
|
||||||
private set
|
|
||||||
|
|
||||||
fun deliver(uris: List<String>) {
|
fun deliver(uris: List<String>) {
|
||||||
if (uris.isNotEmpty()) consumer?.invoke(uris)
|
if (uris.isNotEmpty()) consumer?.invoke(uris)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun enterDropHighlight() {
|
|
||||||
dropHighlightDepth++
|
|
||||||
dropHighlightActive = dropHighlightDepth > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun exitDropHighlight() {
|
|
||||||
if (dropHighlightDepth > 0) dropHighlightDepth--
|
|
||||||
dropHighlightActive = dropHighlightDepth > 0
|
|
||||||
}
|
|
||||||
|
|
||||||
internal fun endDropHighlight() {
|
|
||||||
dropHighlightDepth = 0
|
|
||||||
dropHighlightActive = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() }
|
fun rememberAttachmentDropBridge(): AttachmentDropBridge = remember { AttachmentDropBridge() }
|
||||||
|
|
||||||
|
/** Whether [bridge] currently owns the exclusive drop highlight. */
|
||||||
|
@Composable
|
||||||
|
fun AttachmentDropBridge.isDropHighlightActive(): Boolean =
|
||||||
|
AttachmentDropHighlight.activeBridge === this
|
||||||
|
|
||||||
fun urisToSelectedAttachments(
|
fun urisToSelectedAttachments(
|
||||||
uris: List<String>,
|
uris: List<String>,
|
||||||
existingAttachmentCount: Int,
|
existingAttachmentCount: Int,
|
||||||
@@ -145,6 +199,23 @@ expect fun handleAttachmentDrop(
|
|||||||
onUris: (List<String>) -> Unit,
|
onUris: (List<String>) -> Unit,
|
||||||
): Boolean
|
): Boolean
|
||||||
|
|
||||||
|
private fun syncDropHighlight(event: DragAndDropEvent) {
|
||||||
|
val point = dragPointerInWindow(event) ?: return
|
||||||
|
AttachmentDropHighlight.syncFromPointer(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dispatchDropUris(
|
||||||
|
bridge: AttachmentDropBridge?,
|
||||||
|
uris: List<String>,
|
||||||
|
) {
|
||||||
|
if (AttachmentDropHighlight.deliverToOwner(uris)) return
|
||||||
|
if (bridge != null) {
|
||||||
|
bridge.deliver(uris)
|
||||||
|
} else {
|
||||||
|
GlobalAttachmentDropRouter.deliver(uris)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
fun Modifier.chatAttachmentDropTarget(
|
fun Modifier.chatAttachmentDropTarget(
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
@@ -152,33 +223,45 @@ fun Modifier.chatAttachmentDropTarget(
|
|||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
if (!enabled) return@composed Modifier
|
if (!enabled) return@composed Modifier
|
||||||
val permissionsHost = rememberAttachmentDropPermissionsHost()
|
val permissionsHost = rememberAttachmentDropPermissionsHost()
|
||||||
|
DisposableEffect(bridge) {
|
||||||
|
onDispose { AttachmentDropHighlight.unregister(bridge) }
|
||||||
|
}
|
||||||
val target = remember(bridge, permissionsHost) {
|
val target = remember(bridge, permissionsHost) {
|
||||||
object : DragAndDropTarget {
|
object : DragAndDropTarget {
|
||||||
override fun onStarted(event: DragAndDropEvent) {
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
AttachmentDragSession.isActive = true
|
AttachmentDragSession.begin()
|
||||||
|
syncDropHighlight(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEntered(event: DragAndDropEvent) {
|
override fun onEntered(event: DragAndDropEvent) {
|
||||||
bridge.enterDropHighlight()
|
syncDropHighlight(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(event: DragAndDropEvent) {
|
||||||
|
syncDropHighlight(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onExited(event: DragAndDropEvent) {
|
override fun onExited(event: DragAndDropEvent) {
|
||||||
bridge.exitDropHighlight()
|
syncDropHighlight(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEnded(event: DragAndDropEvent) {
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
bridge.endDropHighlight()
|
AttachmentDragSession.end()
|
||||||
AttachmentDragSession.isActive = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
bridge.endDropHighlight()
|
syncDropHighlight(event)
|
||||||
AttachmentDragSession.isActive = false
|
val accepted = handleAttachmentDrop(permissionsHost, event) { uris ->
|
||||||
return handleAttachmentDrop(permissionsHost, event) { bridge.deliver(it) }
|
dispatchDropUris(bridge, uris)
|
||||||
|
}
|
||||||
|
AttachmentDragSession.end()
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragAndDropTarget(
|
onGloballyPositioned { coords ->
|
||||||
|
AttachmentDropHighlight.register(bridge, coords.boundsInWindow())
|
||||||
|
}.dragAndDropTarget(
|
||||||
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
|
shouldStartDragAndDrop = { acceptsAttachmentDrop(it) },
|
||||||
target = target,
|
target = target,
|
||||||
)
|
)
|
||||||
@@ -194,18 +277,33 @@ fun Modifier.appRootAttachmentDropTarget(): Modifier = composed {
|
|||||||
val target = remember(permissionsHost) {
|
val target = remember(permissionsHost) {
|
||||||
object : DragAndDropTarget {
|
object : DragAndDropTarget {
|
||||||
override fun onStarted(event: DragAndDropEvent) {
|
override fun onStarted(event: DragAndDropEvent) {
|
||||||
AttachmentDragSession.isActive = true
|
AttachmentDragSession.begin()
|
||||||
|
syncDropHighlight(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onMoved(event: DragAndDropEvent) {
|
||||||
|
syncDropHighlight(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onEntered(event: DragAndDropEvent) {
|
||||||
|
syncDropHighlight(event)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onExited(event: DragAndDropEvent) {
|
||||||
|
syncDropHighlight(event)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onEnded(event: DragAndDropEvent) {
|
override fun onEnded(event: DragAndDropEvent) {
|
||||||
AttachmentDragSession.isActive = false
|
AttachmentDragSession.end()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onDrop(event: DragAndDropEvent): Boolean {
|
override fun onDrop(event: DragAndDropEvent): Boolean {
|
||||||
AttachmentDragSession.isActive = false
|
syncDropHighlight(event)
|
||||||
return handleAttachmentDrop(permissionsHost, event) {
|
val accepted = handleAttachmentDrop(permissionsHost, event) { uris ->
|
||||||
GlobalAttachmentDropRouter.deliver(it)
|
dispatchDropUris(bridge = null, uris = uris)
|
||||||
}
|
}
|
||||||
|
AttachmentDragSession.end()
|
||||||
|
return accepted
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,20 +9,18 @@ import androidx.compose.animation.fadeIn
|
|||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.animation.scaleIn
|
import androidx.compose.animation.scaleIn
|
||||||
import androidx.compose.animation.scaleOut
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.navigation.NavHostController
|
import androidx.navigation.NavHostController
|
||||||
import androidx.navigation.compose.NavHost
|
import androidx.navigation.compose.NavHost
|
||||||
import androidx.navigation.compose.composable
|
import androidx.navigation.compose.composable
|
||||||
import androidx.navigation.compose.currentBackStackEntryAsState
|
import androidx.navigation.compose.currentBackStackEntryAsState
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.components.AppPanel
|
|
||||||
|
|
||||||
private val detailNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
private val detailNavTween = tween<Float>(durationMillis = 250, easing = FastOutSlowInEasing)
|
||||||
|
|
||||||
@@ -82,9 +80,8 @@ fun DesktopChatsDetailNavHost(
|
|||||||
* Independent Settings-tab detail [NavHost] for desktop list–detail
|
* Independent Settings-tab detail [NavHost] for desktop list–detail
|
||||||
* (settings screens, own profile, and edit profile on the same stack).
|
* (settings screens, own profile, and edit profile on the same stack).
|
||||||
*
|
*
|
||||||
* [AppPanel] is drawn only behind non-empty destinations so the empty root stays
|
* Settings detail is edge-to-edge on the pane background; empty root stays
|
||||||
* bare (no surface fill), matching the prior AnimatedContent empty vs panel split.
|
* unpainted so the list–detail shell shows through.
|
||||||
* Panel is a sibling under the [NavHost] so the host is not remounted empty ↔ content.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun DesktopSettingsDetailNavHost(
|
fun DesktopSettingsDetailNavHost(
|
||||||
@@ -101,13 +98,11 @@ fun DesktopSettingsDetailNavHost(
|
|||||||
|
|
||||||
Box(modifier.fillMaxSize()) {
|
Box(modifier.fillMaxSize()) {
|
||||||
if (showPanel) {
|
if (showPanel) {
|
||||||
// Match Profile / ConversationListDetailShell (`background`), not the
|
Box(
|
||||||
// default AppPanel `surfaceContainerLowest` (visible mismatch in two-pane).
|
Modifier
|
||||||
AppPanel(
|
.fillMaxSize()
|
||||||
Modifier.fillMaxSize(),
|
.background(MaterialTheme.colorScheme.background),
|
||||||
color = MaterialTheme.colorScheme.background,
|
)
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
CompositionLocalProvider(LocalNavController provides navController) {
|
CompositionLocalProvider(LocalNavController provides navController) {
|
||||||
NavHost(
|
NavHost(
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
package ru.fromchat.ui.main
|
package ru.fromchat.ui.main
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.AnimatedVisibilityScope
|
import androidx.compose.animation.AnimatedVisibilityScope
|
||||||
import androidx.compose.animation.SharedTransitionScope
|
import androidx.compose.animation.SharedTransitionScope
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
@@ -68,12 +71,15 @@ import ru.fromchat.profile
|
|||||||
import ru.fromchat.settings
|
import ru.fromchat.settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
import ru.fromchat.ui.chat.rememberChatSurfaceContainerHazeStyle
|
||||||
|
import ru.fromchat.ui.chat.panels.dm.navigateToDmChat
|
||||||
|
import ru.fromchat.ui.components.BackHandler
|
||||||
import ru.fromchat.ui.components.FromChatSnackbarHost
|
import ru.fromchat.ui.components.FromChatSnackbarHost
|
||||||
import ru.fromchat.ui.components.LocalPaneHazeState
|
import ru.fromchat.ui.components.LocalPaneHazeState
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
|
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayController
|
||||||
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
|
import ru.fromchat.ui.main.chats.ChatContextMenuOverlayHost
|
||||||
|
import ru.fromchat.ui.main.chats.ChatsSearchScreen
|
||||||
import ru.fromchat.ui.main.chats.ChatsTab
|
import ru.fromchat.ui.main.chats.ChatsTab
|
||||||
import ru.fromchat.ui.main.settings.SettingsTab
|
import ru.fromchat.ui.main.settings.SettingsTab
|
||||||
import ru.fromchat.ui.profile.ProfileScreen
|
import ru.fromchat.ui.profile.ProfileScreen
|
||||||
@@ -175,6 +181,17 @@ fun MainScreen(
|
|||||||
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
|
val contextMenuHazeState = paneHazeState ?: rememberHazeState()
|
||||||
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
val chatContextMenuOverlay = remember { ChatContextMenuOverlayController() }
|
||||||
var chatListSelectionRequestId by remember { mutableStateOf(0L) }
|
var chatListSelectionRequestId by remember { mutableStateOf(0L) }
|
||||||
|
var paneSearchOpen by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
|
fun openConversationSearch() {
|
||||||
|
if (embeddedInListDetail) {
|
||||||
|
paneSearchOpen = true
|
||||||
|
} else {
|
||||||
|
navController.navigate("search/conversations") {
|
||||||
|
launchSingleTop = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
|
val widthClass = currentWindowAdaptiveInfo().widthSizeClass
|
||||||
|
|
||||||
@@ -193,11 +210,12 @@ fun MainScreen(
|
|||||||
if (pagerState.currentPage != PAGE_CHATS) {
|
if (pagerState.currentPage != PAGE_CHATS) {
|
||||||
pagerState.scrollToPage(PAGE_CHATS)
|
pagerState.scrollToPage(PAGE_CHATS)
|
||||||
}
|
}
|
||||||
navController.navigate("search/conversations") {
|
openConversationSearch()
|
||||||
launchSingleTop = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
DesktopMenuCommand.EnterChatListSelection -> {
|
DesktopMenuCommand.EnterChatListSelection -> {
|
||||||
|
if (paneSearchOpen) {
|
||||||
|
paneSearchOpen = false
|
||||||
|
}
|
||||||
if (navController.currentBackStackEntry?.destination?.route ==
|
if (navController.currentBackStackEntry?.destination?.route ==
|
||||||
"search/conversations"
|
"search/conversations"
|
||||||
) {
|
) {
|
||||||
@@ -282,9 +300,7 @@ fun MainScreen(
|
|||||||
when (page) {
|
when (page) {
|
||||||
PAGE_CHATS -> ChatsTab(
|
PAGE_CHATS -> ChatsTab(
|
||||||
isVisible = isChatsPage,
|
isVisible = isChatsPage,
|
||||||
onOpenSearch = {
|
onOpenSearch = { openConversationSearch() },
|
||||||
navController.navigate("search/conversations")
|
|
||||||
},
|
|
||||||
chatContextMenuOverlay = chatContextMenuOverlay,
|
chatContextMenuOverlay = chatContextMenuOverlay,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
@@ -429,6 +445,35 @@ fun MainScreen(
|
|||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.zIndex(3f),
|
.zIndex(3f),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if (embeddedInListDetail) {
|
||||||
|
BackHandler(paneSearchOpen) { paneSearchOpen = false }
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = paneSearchOpen,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.zIndex(4f),
|
||||||
|
) {
|
||||||
|
val chatsNav = LocalDesktopChatsNavController.current ?: navController
|
||||||
|
ChatsSearchScreen(
|
||||||
|
onBack = { paneSearchOpen = false },
|
||||||
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
|
animatedVisibilityScope = this,
|
||||||
|
onOpenProfile = { userId: Int ->
|
||||||
|
if (userId == 0) return@ChatsSearchScreen
|
||||||
|
paneSearchOpen = false
|
||||||
|
chatsNav.navigateReplacingMainDetail("profile/$userId")
|
||||||
|
},
|
||||||
|
onOpenConversation = { userId: Int ->
|
||||||
|
if (userId == 0) return@ChatsSearchScreen
|
||||||
|
paneSearchOpen = false
|
||||||
|
chatsNav.navigateToDmChat(userId)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ import androidx.compose.ui.util.fastAll
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox
|
import ru.fromchat.ui.chat.utils.AttachmentDropHighlightBox
|
||||||
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
|
import ru.fromchat.ui.chat.utils.chatAttachmentDropTarget
|
||||||
|
import ru.fromchat.ui.chat.utils.isDropHighlightActive
|
||||||
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
|
import ru.fromchat.ui.chat.utils.rememberAttachmentDropBridge
|
||||||
import ru.fromchat.ui.components.SearchBar
|
import ru.fromchat.ui.components.SearchBar
|
||||||
import ru.fromchat.ui.components.SearchBarSharedElement
|
import ru.fromchat.ui.components.SearchBarSharedElement
|
||||||
@@ -868,7 +869,7 @@ internal fun PublicChatRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AttachmentDropHighlightBox(
|
AttachmentDropHighlightBox(
|
||||||
active = dropBridge.dropHighlightActive,
|
active = dropBridge.isDropHighlightActive(),
|
||||||
shape = clipShape,
|
shape = clipShape,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@@ -1035,7 +1036,7 @@ internal fun DmConversationRow(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AttachmentDropHighlightBox(
|
AttachmentDropHighlightBox(
|
||||||
active = dropBridge.dropHighlightActive,
|
active = dropBridge.isDropHighlightActive(),
|
||||||
shape = clipShape,
|
shape = clipShape,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
|||||||
@@ -432,6 +432,7 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
topBar = {
|
topBar = {
|
||||||
val topBarHazeStyle = HazeMaterials.thin()
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (settingsDetailShowBackButton()) {
|
if (settingsDetailShowBackButton()) {
|
||||||
|
|||||||
@@ -448,6 +448,7 @@ fun LogFilesScreen(
|
|||||||
topBar = {
|
topBar = {
|
||||||
Box {
|
Box {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.graphicsLayer { alpha = 1f - selectionProgress }
|
.graphicsLayer { alpha = 1f - selectionProgress }
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainer),
|
.background(MaterialTheme.colorScheme.surfaceContainer),
|
||||||
@@ -473,6 +474,7 @@ fun LogFilesScreen(
|
|||||||
)
|
)
|
||||||
if (selectionMode || selectionProgress > 0f) {
|
if (selectionMode || selectionProgress > 0f) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
@@ -770,6 +770,7 @@ fun LogsScreen() {
|
|||||||
topBar = {
|
topBar = {
|
||||||
Box {
|
Box {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.graphicsLayer {
|
.graphicsLayer {
|
||||||
alpha = (1f - selectionProgress) * (1f - searchProgress)
|
alpha = (1f - selectionProgress) * (1f - searchProgress)
|
||||||
@@ -884,6 +885,7 @@ fun LogsScreen() {
|
|||||||
)
|
)
|
||||||
if (searchMode) {
|
if (searchMode) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
modifier = Modifier.graphicsLayer { alpha = searchProgress },
|
modifier = Modifier.graphicsLayer { alpha = searchProgress },
|
||||||
colors = logsTransparentTopAppBarColors(),
|
colors = logsTransparentTopAppBarColors(),
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
@@ -944,6 +946,7 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
if (selectionMode) {
|
if (selectionMode) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
modifier = Modifier.graphicsLayer { alpha = selectionProgress },
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(
|
IconButton(
|
||||||
|
|||||||
+4
-8
@@ -34,7 +34,6 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.ui.extraStatusBars
|
import ru.fromchat.ui.extraStatusBars
|
||||||
import ru.fromchat.ui.main.LocalConversationListDetailActive
|
|
||||||
import ru.fromchat.ui.main.detailPaneShowBackButton
|
import ru.fromchat.ui.main.detailPaneShowBackButton
|
||||||
|
|
||||||
/** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */
|
/** Idle gap after mouse-wheel / trackpad deltas before snapping the collapsing bar. */
|
||||||
@@ -44,6 +43,9 @@ private const val AppBarWheelSettleDelayMs = 64L
|
|||||||
@Composable
|
@Composable
|
||||||
fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
|
fun settingsDetailShowBackButton(): Boolean = detailPaneShowBackButton()
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun settingsDetailWindowInsets(): WindowInsets = WindowInsets.extraStatusBars
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun settingsDetailUseCollapsingTopBar(): Boolean =
|
fun settingsDetailUseCollapsingTopBar(): Boolean =
|
||||||
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT
|
currentWindowAdaptiveInfo().widthSizeClass == WindowWidthSizeClass.COMPACT
|
||||||
@@ -88,13 +90,7 @@ fun SettingsDetailTopBar(
|
|||||||
// `surfaceContainerLowest` — that token reads as a separate pane fill in
|
// `surfaceContainerLowest` — that token reads as a separate pane fill in
|
||||||
// two-pane against the list–detail shell.
|
// two-pane against the list–detail shell.
|
||||||
val paneColor = MaterialTheme.colorScheme.background
|
val paneColor = MaterialTheme.colorScheme.background
|
||||||
// List–detail shell already pads/consumes extraStatusBars; full-screen settings
|
val topBarWindowInsets = settingsDetailWindowInsets()
|
||||||
// (e.g. logged-out About) need the desktop title-bar inset explicitly.
|
|
||||||
val topBarWindowInsets = if (LocalConversationListDetailActive.current) {
|
|
||||||
WindowInsets(0, 0, 0, 0)
|
|
||||||
} else {
|
|
||||||
WindowInsets.extraStatusBars
|
|
||||||
}
|
|
||||||
if (settingsDetailUseCollapsingTopBar()) {
|
if (settingsDetailUseCollapsingTopBar()) {
|
||||||
MediumTopAppBar(
|
MediumTopAppBar(
|
||||||
title = title,
|
title = title,
|
||||||
|
|||||||
+4
-1
@@ -121,6 +121,8 @@ import ru.fromchat.ui.components.rememberLazyListFocusScrollState
|
|||||||
import ru.fromchat.ui.components.trackLazyListFocus
|
import ru.fromchat.ui.components.trackLazyListFocus
|
||||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||||
import ru.fromchat.ui.main.settings.settingsDetailShowBackButton
|
import ru.fromchat.ui.main.settings.settingsDetailShowBackButton
|
||||||
|
import ru.fromchat.ui.main.settings.settingsDetailWindowInsets
|
||||||
|
import ru.fromchat.ui.extraStatusBars
|
||||||
|
|
||||||
private object ServerConfigLazyListIndices {
|
private object ServerConfigLazyListIndices {
|
||||||
const val SERVER_IP_FIELD = 2
|
const val SERVER_IP_FIELD = 2
|
||||||
@@ -339,6 +341,7 @@ fun ServerConfigScreen() {
|
|||||||
topBar = {
|
topBar = {
|
||||||
val topBarHazeStyle = HazeMaterials.thin()
|
val topBarHazeStyle = HazeMaterials.thin()
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
|
windowInsets = settingsDetailWindowInsets(),
|
||||||
title = {},
|
title = {},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
if (settingsDetailShowBackButton()) {
|
if (settingsDetailShowBackButton()) {
|
||||||
@@ -366,7 +369,7 @@ fun ServerConfigScreen() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
) { innerPadding ->
|
) { innerPadding ->
|
||||||
val floatingHeaderClearance = WindowInsets.statusBars.getTop(density).toDp(density) + 68.dp
|
val floatingHeaderClearance = WindowInsets.extraStatusBars.getTop(density).toDp(density) + 68.dp
|
||||||
val bottomInsetPadding = innerPadding.calculateBottomPadding()
|
val bottomInsetPadding = innerPadding.calculateBottomPadding()
|
||||||
val serverConfigListState = rememberLazyListState()
|
val serverConfigListState = rememberLazyListState()
|
||||||
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
var listViewportBounds by remember { mutableStateOf<Rect?>(null) }
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import androidx.compose.ui.window.PopupProperties
|
|||||||
internal actual fun MessageContextMenuPopup(
|
internal actual fun MessageContextMenuPopup(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
positionProvider: PopupPositionProvider,
|
positionProvider: PopupPositionProvider,
|
||||||
|
reserveOvershoot: Boolean,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Popup(
|
Popup(
|
||||||
@@ -18,7 +19,7 @@ internal actual fun MessageContextMenuPopup(
|
|||||||
focusable = true,
|
focusable = true,
|
||||||
dismissOnBackPress = true,
|
dismissOnBackPress = true,
|
||||||
dismissOnClickOutside = true,
|
dismissOnClickOutside = true,
|
||||||
clippingEnabled = true,
|
clippingEnabled = !reserveOvershoot,
|
||||||
),
|
),
|
||||||
content = content,
|
content = content,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ru.fromchat.ui.chat.utils
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
|
||||||
actual class AttachmentDropPermissionsHost
|
actual class AttachmentDropPermissionsHost
|
||||||
|
|
||||||
@@ -9,6 +10,8 @@ actual class AttachmentDropPermissionsHost
|
|||||||
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
|
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
|
||||||
AttachmentDropPermissionsHost()
|
AttachmentDropPermissionsHost()
|
||||||
|
|
||||||
|
actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? = null
|
||||||
|
|
||||||
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false
|
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean = false
|
||||||
|
|
||||||
actual fun handleAttachmentDrop(
|
actual fun handleAttachmentDrop(
|
||||||
|
|||||||
@@ -3,11 +3,31 @@ package ru.fromchat.ui
|
|||||||
import androidx.compose.material3.darkColorScheme
|
import androidx.compose.material3.darkColorScheme
|
||||||
import androidx.compose.material3.lightColorScheme
|
import androidx.compose.material3.lightColorScheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import java.awt.Window
|
||||||
|
import javax.swing.RootPaneContainer
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
actual fun getColorScheme(darkTheme: Boolean, dynamicColor: Boolean) =
|
||||||
if (darkTheme) darkColorScheme() else lightColorScheme()
|
if (darkTheme) darkColorScheme() else lightColorScheme()
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) = Unit
|
actual fun ApplySystemBarTheme(darkTheme: Boolean, surfaceColor: Color) {
|
||||||
|
SideEffect {
|
||||||
|
applyMacOsAppAppearance(darkTheme)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun applyMacOsAppAppearance(dark: Boolean) {
|
||||||
|
val os = System.getProperty("os.name").orEmpty().lowercase()
|
||||||
|
if (!os.contains("mac")) return
|
||||||
|
val appearance = if (dark) "NSAppearanceNameDarkAqua" else "NSAppearanceNameAqua"
|
||||||
|
System.setProperty("apple.awt.application.appearance", appearance)
|
||||||
|
for (window in Window.getWindows()) {
|
||||||
|
if (window is RootPaneContainer) {
|
||||||
|
window.rootPane.putClientProperty("apple.awt.application.appearance", appearance)
|
||||||
|
window.rootPane.putClientProperty("apple.awt.windowAppearance", appearance)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,30 +1,213 @@
|
|||||||
|
@file:OptIn(androidx.compose.ui.ExperimentalComposeUiApi::class)
|
||||||
|
|
||||||
package ru.fromchat.ui.chat
|
package ru.fromchat.ui.chat
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.wrapContentWidth
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.window.Popup
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.SideEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberUpdatedState
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.awt.LocalAwtWindow
|
||||||
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
|
import androidx.compose.ui.unit.Density
|
||||||
|
import androidx.compose.ui.unit.DpSize
|
||||||
|
import androidx.compose.ui.unit.IntOffset
|
||||||
|
import androidx.compose.ui.unit.IntRect
|
||||||
|
import androidx.compose.ui.unit.IntSize
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.window.PopupPositionProvider
|
import androidx.compose.ui.window.PopupPositionProvider
|
||||||
import androidx.compose.ui.window.PopupProperties
|
import androidx.compose.ui.window.Window
|
||||||
|
import androidx.compose.ui.window.WindowPosition
|
||||||
|
import androidx.compose.ui.window.rememberWindowState
|
||||||
|
import java.awt.AWTEvent
|
||||||
|
import java.awt.Point
|
||||||
|
import java.awt.Toolkit
|
||||||
|
import java.awt.event.AWTEventListener
|
||||||
|
import java.awt.event.KeyEvent
|
||||||
|
import java.awt.event.MouseEvent
|
||||||
|
import javax.swing.RootPaneContainer
|
||||||
|
import javax.swing.SwingUtilities
|
||||||
|
|
||||||
|
/** Extra OS-window size so the LowBouncy scale can overshoot the laid-out menu. */
|
||||||
|
private const val MenuOvershootReserve = 0.15f
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Desktop uses an in-window [Popup] (not a separate OS window) so opening the menu does not
|
* Desktop message menus use a real OS [Window] so they can hang outside the main app frame.
|
||||||
* steal focus from the main frame. [PopupProperties.clippingEnabled] is false so the menu can
|
* The window is not focusable so opening it does not defocus the chat. The opening right-click
|
||||||
* extend past the window edge when placement allows it.
|
* is ignored until it is released; later presses outside the menu dismiss it.
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
internal actual fun MessageContextMenuPopup(
|
internal actual fun MessageContextMenuPopup(
|
||||||
onDismissRequest: () -> Unit,
|
onDismissRequest: () -> Unit,
|
||||||
positionProvider: PopupPositionProvider,
|
positionProvider: PopupPositionProvider,
|
||||||
|
reserveOvershoot: Boolean,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit,
|
||||||
) {
|
) {
|
||||||
Popup(
|
val density = LocalDensity.current
|
||||||
onDismissRequest = onDismissRequest,
|
val layoutDirection = LocalLayoutDirection.current
|
||||||
popupPositionProvider = positionProvider,
|
val containerSize = LocalWindowInfo.current.containerSize
|
||||||
properties = PopupProperties(
|
val parentWindow = LocalAwtWindow.current
|
||||||
focusable = true,
|
val onDismiss by rememberUpdatedState(onDismissRequest)
|
||||||
dismissOnBackPress = true,
|
var menuSize by remember { mutableStateOf(IntSize.Zero) }
|
||||||
dismissOnClickOutside = true,
|
|
||||||
clippingEnabled = false,
|
val positionInWindow = remember(positionProvider, menuSize, containerSize, layoutDirection) {
|
||||||
),
|
positionProvider.calculatePosition(
|
||||||
content = content,
|
anchorBounds = IntRect.Zero,
|
||||||
|
windowSize = containerSize,
|
||||||
|
layoutDirection = layoutDirection,
|
||||||
|
popupContentSize = menuSize,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
val positionOnScreen = remember(positionInWindow, parentWindow, density) {
|
||||||
|
composeOffsetToScreen(parentWindow, positionInWindow, density)
|
||||||
|
}
|
||||||
|
|
||||||
|
val shadowPad = 12.dp
|
||||||
|
val overshootPad = if (reserveOvershoot && menuSize != IntSize.Zero) {
|
||||||
|
with(density) {
|
||||||
|
DpSize(
|
||||||
|
(menuSize.width * MenuOvershootReserve).toDp(),
|
||||||
|
(menuSize.height * MenuOvershootReserve).toDp(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
DpSize(0.dp, 0.dp)
|
||||||
|
}
|
||||||
|
val windowSize = if (menuSize == IntSize.Zero) {
|
||||||
|
// Compose Desktop cannot actualize Unspecified window sizes.
|
||||||
|
DpSize(360.dp, 560.dp)
|
||||||
|
} else {
|
||||||
|
with(density) {
|
||||||
|
DpSize(
|
||||||
|
menuSize.width.toDp() + shadowPad * 2 + overshootPad.width,
|
||||||
|
menuSize.height.toDp() + shadowPad * 2 + overshootPad.height,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val windowPosition = WindowPosition(
|
||||||
|
x = positionOnScreen.x - shadowPad,
|
||||||
|
y = positionOnScreen.y - shadowPad,
|
||||||
|
)
|
||||||
|
|
||||||
|
val windowState = rememberWindowState(
|
||||||
|
position = windowPosition,
|
||||||
|
size = windowSize,
|
||||||
|
)
|
||||||
|
SideEffect {
|
||||||
|
if (windowState.position != windowPosition) windowState.position = windowPosition
|
||||||
|
if (windowState.size != windowSize) windowState.size = windowSize
|
||||||
|
}
|
||||||
|
|
||||||
|
Window(
|
||||||
|
onCloseRequest = onDismiss,
|
||||||
|
state = windowState,
|
||||||
|
title = "",
|
||||||
|
undecorated = true,
|
||||||
|
transparent = true,
|
||||||
|
resizable = false,
|
||||||
|
alwaysOnTop = true,
|
||||||
|
focusable = false,
|
||||||
|
) {
|
||||||
|
val menuWindow = window
|
||||||
|
DisposableEffect(menuWindow) {
|
||||||
|
menuWindow.isAlwaysOnTop = true
|
||||||
|
onDispose { }
|
||||||
|
}
|
||||||
|
DisposableEffect(menuWindow, onDismiss) {
|
||||||
|
val toolkit = Toolkit.getDefaultToolkit()
|
||||||
|
var armed = false
|
||||||
|
val listener = AWTEventListener { awtEvent ->
|
||||||
|
when (awtEvent.id) {
|
||||||
|
MouseEvent.MOUSE_RELEASED -> armed = true
|
||||||
|
MouseEvent.MOUSE_PRESSED -> {
|
||||||
|
if (!armed) return@AWTEventListener
|
||||||
|
val mouse = awtEvent as MouseEvent
|
||||||
|
val sourceWindow = SwingUtilities.getWindowAncestor(mouse.component)
|
||||||
|
?: mouse.component as? java.awt.Window
|
||||||
|
if (sourceWindow !== menuWindow) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyEvent.KEY_PRESSED -> {
|
||||||
|
if ((awtEvent as KeyEvent).keyCode == KeyEvent.VK_ESCAPE) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
toolkit.addAWTEventListener(
|
||||||
|
listener,
|
||||||
|
AWTEvent.MOUSE_EVENT_MASK or AWTEvent.KEY_EVENT_MASK,
|
||||||
|
)
|
||||||
|
onDispose { toolkit.removeAWTEventListener(listener) }
|
||||||
|
}
|
||||||
|
Box(Modifier.fillMaxSize()) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.padding(shadowPad),
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
// Unbounded width only — verticalScroll rejects infinite max height.
|
||||||
|
modifier = Modifier
|
||||||
|
.wrapContentWidth(unbounded = true)
|
||||||
|
.onSizeChanged { size ->
|
||||||
|
if (size.width <= 0 || size.height <= 0) return@onSizeChanged
|
||||||
|
// Grow-only: a lagging OS resize must not stick a squeezed width
|
||||||
|
// for the rest of the spring.
|
||||||
|
if (
|
||||||
|
menuSize == IntSize.Zero ||
|
||||||
|
size.width > menuSize.width ||
|
||||||
|
size.height > menuSize.height
|
||||||
|
) {
|
||||||
|
menuSize = IntSize(
|
||||||
|
maxOf(menuSize.width, size.width),
|
||||||
|
maxOf(menuSize.height, size.height),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compose Desktop [WindowPosition] maps `Dp.value` 1:1 onto AWT screen pixels. Compose
|
||||||
|
* [IntOffset]s from `localToWindow` are density-scaled pixels, so they must be converted
|
||||||
|
* with [androidx.compose.ui.unit.Density] instead of added to the AWT origin first.
|
||||||
|
*/
|
||||||
|
private fun composeOffsetToScreen(
|
||||||
|
parentWindow: java.awt.Window?,
|
||||||
|
offsetInWindow: IntOffset,
|
||||||
|
density: Density,
|
||||||
|
): WindowPosition {
|
||||||
|
val origin = parentWindow?.composeLocalOriginOnScreen() ?: Point()
|
||||||
|
return WindowPosition(
|
||||||
|
x = origin.x.dp + with(density) { offsetInWindow.x.toDp() },
|
||||||
|
y = origin.y.dp + with(density) { offsetInWindow.y.toDp() },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun java.awt.Window.composeLocalOriginOnScreen(): Point {
|
||||||
|
val root = (this as? RootPaneContainer)?.contentPane ?: this
|
||||||
|
return runCatching {
|
||||||
|
Point(0, 0).also { SwingUtilities.convertPointToScreen(it, root) }
|
||||||
|
}.getOrElse {
|
||||||
|
runCatching { locationOnScreen }.getOrDefault(Point())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,11 @@ package ru.fromchat.ui.chat.utils
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
import androidx.compose.ui.draganddrop.DragAndDropEvent
|
||||||
|
import androidx.compose.ui.draganddrop.DragData
|
||||||
import androidx.compose.ui.draganddrop.awtTransferable
|
import androidx.compose.ui.draganddrop.awtTransferable
|
||||||
|
import androidx.compose.ui.draganddrop.dragData
|
||||||
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
import java.awt.MouseInfo
|
||||||
import java.awt.datatransfer.DataFlavor
|
import java.awt.datatransfer.DataFlavor
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
@@ -15,15 +19,33 @@ actual class AttachmentDropPermissionsHost
|
|||||||
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
|
actual fun rememberAttachmentDropPermissionsHost(): AttachmentDropPermissionsHost =
|
||||||
AttachmentDropPermissionsHost()
|
AttachmentDropPermissionsHost()
|
||||||
|
|
||||||
|
actual fun dragPointerInWindow(event: DragAndDropEvent): Offset? {
|
||||||
|
val pointer = MouseInfo.getPointerInfo()?.location ?: return null
|
||||||
|
val window = java.awt.KeyboardFocusManager.getCurrentKeyboardFocusManager().activeWindow
|
||||||
|
?: java.awt.Window.getWindows().firstOrNull { it.isShowing }
|
||||||
|
?: return null
|
||||||
|
val origin = runCatching { window.locationOnScreen }.getOrNull() ?: return null
|
||||||
|
return Offset((pointer.x - origin.x).toFloat(), (pointer.y - origin.y).toFloat())
|
||||||
|
}
|
||||||
|
|
||||||
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
|
actual fun acceptsAttachmentDrop(event: DragAndDropEvent): Boolean {
|
||||||
val transferable = event.awtTransferable
|
return runCatching {
|
||||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) return true
|
when (val data = event.dragData()) {
|
||||||
return transferable.transferDataFlavors.any { flavor ->
|
is DragData.FilesList -> true
|
||||||
val mime = flavor.mimeType.orEmpty()
|
is DragData.Image -> true
|
||||||
mime.startsWith("image/") ||
|
else -> {
|
||||||
mime == "application/octet-stream" ||
|
val transferable = event.awtTransferable
|
||||||
flavor == DataFlavor.stringFlavor
|
transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor) ||
|
||||||
}
|
transferable.transferDataFlavors.any { flavor ->
|
||||||
|
val mime = flavor.mimeType.orEmpty()
|
||||||
|
mime.startsWith("image/") ||
|
||||||
|
mime == "text/uri-list" ||
|
||||||
|
mime == "application/octet-stream" ||
|
||||||
|
flavor == DataFlavor.stringFlavor
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}.getOrDefault(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
actual fun handleAttachmentDrop(
|
actual fun handleAttachmentDrop(
|
||||||
@@ -31,33 +53,83 @@ actual fun handleAttachmentDrop(
|
|||||||
event: DragAndDropEvent,
|
event: DragAndDropEvent,
|
||||||
onUris: (List<String>) -> Unit,
|
onUris: (List<String>) -> Unit,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val uris = extractAttachmentDropUris(event)
|
val uris = runCatching { extractAttachmentDropUris(event) }.getOrDefault(emptyList())
|
||||||
if (uris.isEmpty()) return false
|
if (uris.isEmpty()) return false
|
||||||
onUris(uris)
|
onUris(uris)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun extractAttachmentDropUris(event: DragAndDropEvent): List<String> {
|
private fun extractAttachmentDropUris(event: DragAndDropEvent): List<String> {
|
||||||
|
// Prefer Compose's AWT helper — returns file:// URIs for javaFileListFlavor.
|
||||||
|
when (val data = event.dragData()) {
|
||||||
|
is DragData.FilesList -> {
|
||||||
|
val paths = data.readFiles().mapNotNull { uriStringToLocalPath(it) }.distinct()
|
||||||
|
if (paths.isNotEmpty()) return paths
|
||||||
|
}
|
||||||
|
else -> Unit
|
||||||
|
}
|
||||||
|
|
||||||
val transferable = event.awtTransferable
|
val transferable = event.awtTransferable
|
||||||
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
if (transferable.isDataFlavorSupported(DataFlavor.javaFileListFlavor)) {
|
||||||
val files = transferable.getTransferData(DataFlavor.javaFileListFlavor) as? List<*>
|
val raw = transferable.getTransferData(DataFlavor.javaFileListFlavor)
|
||||||
return files.orEmpty().mapNotNull { entry ->
|
val files = when (raw) {
|
||||||
(entry as? File)?.absolutePath?.takeIf { it.isNotBlank() }
|
is List<*> -> raw
|
||||||
|
is Array<*> -> raw.toList()
|
||||||
|
else -> emptyList()
|
||||||
|
}
|
||||||
|
val paths = files.mapNotNull { entry ->
|
||||||
|
when (entry) {
|
||||||
|
is File -> entry.absolutePath.takeIf { it.isNotBlank() }
|
||||||
|
is String -> uriStringToLocalPath(entry)
|
||||||
|
else -> null
|
||||||
|
}
|
||||||
}.distinct()
|
}.distinct()
|
||||||
|
if (paths.isNotEmpty()) return paths
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val uriListFlavor = runCatching {
|
||||||
|
DataFlavor("text/uri-list;class=java.lang.String")
|
||||||
|
}.getOrNull()
|
||||||
|
if (uriListFlavor != null && transferable.isDataFlavorSupported(uriListFlavor)) {
|
||||||
|
val text = (transferable.getTransferData(uriListFlavor) as? String).orEmpty()
|
||||||
|
val paths = text.lineSequence()
|
||||||
|
.map { it.trim() }
|
||||||
|
.filter { it.isNotEmpty() && !it.startsWith("#") }
|
||||||
|
.mapNotNull { uriStringToLocalPath(it) }
|
||||||
|
.distinct()
|
||||||
|
.toList()
|
||||||
|
if (paths.isNotEmpty()) return paths
|
||||||
|
}
|
||||||
|
|
||||||
if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
if (transferable.isDataFlavorSupported(DataFlavor.stringFlavor)) {
|
||||||
val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty()
|
val text = (transferable.getTransferData(DataFlavor.stringFlavor) as? String)?.trim().orEmpty()
|
||||||
if (text.startsWith("file:")) {
|
if (text.isNotEmpty()) {
|
||||||
val path = runCatching { URI(text).path }.getOrNull()?.takeIf { it.isNotBlank() }
|
val fromLines = text.lineSequence()
|
||||||
?: text.removePrefix("file://")
|
.map { it.trim() }
|
||||||
return listOf(path)
|
.filter { it.isNotEmpty() }
|
||||||
}
|
.mapNotNull { uriStringToLocalPath(it) }
|
||||||
if (text.startsWith("/") || looksLikeWindowsPath(text)) {
|
.distinct()
|
||||||
return listOf(text)
|
.toList()
|
||||||
|
if (fromLines.isNotEmpty()) return fromLines
|
||||||
|
uriStringToLocalPath(text)?.let { return listOf(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return emptyList()
|
return emptyList()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Accepts `file://` URIs, plain absolute paths, and Windows paths; returns a local filesystem path. */
|
||||||
|
private fun uriStringToLocalPath(text: String): String? {
|
||||||
|
val trimmed = text.trim().takeIf { it.isNotBlank() } ?: return null
|
||||||
|
if (trimmed.startsWith("file:")) {
|
||||||
|
val path = runCatching { URI(trimmed).path }.getOrNull()?.takeIf { it.isNotBlank() }
|
||||||
|
?: trimmed.removePrefix("file://")
|
||||||
|
return path.takeIf { File(it).isFile || File(it).exists() } ?: path.takeIf { it.isNotBlank() }
|
||||||
|
}
|
||||||
|
if (trimmed.startsWith("/") || looksLikeWindowsPath(trimmed)) {
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
private fun looksLikeWindowsPath(text: String): Boolean =
|
private fun looksLikeWindowsPath(text: String): Boolean =
|
||||||
text.length > 2 && text[0].isLetter() && text[1] == ':'
|
text.length > 2 && text[0].isLetter() && text[1] == ':'
|
||||||
|
|||||||
@@ -2,14 +2,18 @@ package ru.fromchat.ui.chat.utils
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||||
|
import androidx.compose.ui.awt.LocalAwtWindow
|
||||||
import java.awt.FileDialog
|
import java.awt.FileDialog
|
||||||
import java.awt.Frame
|
import java.awt.Frame
|
||||||
|
import java.awt.Window
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import java.net.URI
|
import java.net.URI
|
||||||
import javax.imageio.ImageIO
|
import javax.imageio.ImageIO
|
||||||
import javax.swing.JFileChooser
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
import ru.fromchat.ui.applyMacOsAppAppearance
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
|
||||||
actual fun getFilenameFromUri(uri: String): String {
|
actual fun getFilenameFromUri(uri: String): String {
|
||||||
val path = when {
|
val path = when {
|
||||||
@@ -19,41 +23,66 @@ actual fun getFilenameFromUri(uri: String): String {
|
|||||||
return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
|
return path.substringAfterLast('/').takeIf { it.isNotBlank() } ?: "file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
actual fun rememberImagePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
||||||
return remember(onResult) {
|
val owner = LocalAwtWindow.current
|
||||||
|
val dark = isAppInDarkTheme()
|
||||||
|
return remember(onResult, owner, dark) {
|
||||||
{
|
{
|
||||||
val dialog = FileDialog(null as Frame?, "Select images", FileDialog.LOAD).apply {
|
showNativeFilePicker(
|
||||||
isMultipleMode = true
|
owner = owner,
|
||||||
setFilenameFilter { _, name ->
|
dark = dark,
|
||||||
val lower = name.lowercase()
|
imagesOnly = true,
|
||||||
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
|
onResult = onResult,
|
||||||
lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp")
|
)
|
||||||
}
|
|
||||||
}
|
|
||||||
dialog.isVisible = true
|
|
||||||
val dir = dialog.directory
|
|
||||||
val files = dialog.files
|
|
||||||
if (dir != null && files != null && files.isNotEmpty()) {
|
|
||||||
onResult(files.map { File(dir, it.name).absolutePath })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalComposeUiApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
actual fun rememberFilePicker(onResult: (List<String>) -> Unit): () -> Unit {
|
||||||
return remember(onResult) {
|
val owner = LocalAwtWindow.current
|
||||||
|
val dark = isAppInDarkTheme()
|
||||||
|
return remember(onResult, owner, dark) {
|
||||||
{
|
{
|
||||||
val chooser = JFileChooser().apply {
|
showNativeFilePicker(
|
||||||
isMultiSelectionEnabled = true
|
owner = owner,
|
||||||
fileSelectionMode = JFileChooser.FILES_ONLY
|
dark = dark,
|
||||||
}
|
imagesOnly = false,
|
||||||
if (chooser.showOpenDialog(null) == JFileChooser.APPROVE_OPTION) {
|
onResult = onResult,
|
||||||
onResult(chooser.selectedFiles.map { it.absolutePath })
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun showNativeFilePicker(
|
||||||
|
owner: Window?,
|
||||||
|
dark: Boolean,
|
||||||
|
imagesOnly: Boolean,
|
||||||
|
onResult: (List<String>) -> Unit,
|
||||||
|
) {
|
||||||
|
applyMacOsAppAppearance(dark)
|
||||||
|
val dialog = when (owner) {
|
||||||
|
is Frame -> FileDialog(owner, if (imagesOnly) "Select images" else "Select files", FileDialog.LOAD)
|
||||||
|
else -> FileDialog(null as Frame?, if (imagesOnly) "Select images" else "Select files", FileDialog.LOAD)
|
||||||
|
}.apply {
|
||||||
|
isMultipleMode = true
|
||||||
|
if (imagesOnly) {
|
||||||
|
setFilenameFilter { _, name ->
|
||||||
|
val lower = name.lowercase()
|
||||||
|
lower.endsWith(".png") || lower.endsWith(".jpg") || lower.endsWith(".jpeg") ||
|
||||||
|
lower.endsWith(".gif") || lower.endsWith(".webp") || lower.endsWith(".bmp")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
dialog.isVisible = true
|
||||||
|
val dir = dialog.directory
|
||||||
|
val files = dialog.files
|
||||||
|
if (dir != null && files != null && files.isNotEmpty()) {
|
||||||
|
onResult(files.map { File(dir, it.name).absolutePath })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
actual suspend fun getImageAspectRatio(uri: String): Float? {
|
actual suspend fun getImageAspectRatio(uri: String): Float? {
|
||||||
|
|||||||
Reference in New Issue
Block a user