feat(chat): redesign attachment picker tray (#1130)

This commit is contained in:
M57 authored and GitHub committed 2026-09-15 21:56:06 +03:00
1 parent 984053b0e7
commit afffcd95e8
9 files changed
+417 -81

No files matched your search

@@ -0,0 +1,229 @@
package com.m57.hermescontrol.ui.chat.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.MutableTransitionState
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.widthIn
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.CameraAlt
import androidx.compose.material.icons.filled.PhotoLibrary
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.role
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntRect
import androidx.compose.ui.unit.IntSize
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Popup
import androidx.compose.ui.window.PopupPositionProvider
import androidx.compose.ui.window.PopupProperties
import com.m57.hermescontrol.R
/**
* Compact attachment tray that extends the composer without looking like a
* separate system menu. It is hosted in a focusable popup so outside taps and
* the system back action dismiss it naturally.
*/
@Composable
internal fun AttachmentTray(
visible: Boolean,
onDismissRequest: () -> Unit,
onCameraTap: () -> Unit,
onImageTap: () -> Unit,
onFileTap: () -> Unit,
) {
val transitionState = remember { MutableTransitionState(visible) }
LaunchedEffect(visible) {
transitionState.targetState = visible
}
if (!visible && !transitionState.currentState) {
return
}
val marginPx = with(LocalDensity.current) { 8.dp.roundToPx() }
val trayPositionProvider =
remember(marginPx) {
AttachmentTrayPositionProvider(marginPx = marginPx)
}
Popup(
popupPositionProvider = trayPositionProvider,
onDismissRequest = onDismissRequest,
properties =
PopupProperties(
focusable = visible,
dismissOnBackPress = visible,
dismissOnClickOutside = visible,
),
) {
AnimatedVisibility(
visibleState = transitionState,
enter =
fadeIn(animationSpec = tween(durationMillis = 160)) +
slideInVertically(
animationSpec = tween(durationMillis = 160),
initialOffsetY = { it / 4 },
) +
scaleIn(
animationSpec = tween(durationMillis = 160),
initialScale = 0.96f,
),
exit =
fadeOut(animationSpec = tween(durationMillis = 120)) +
slideOutVertically(
animationSpec = tween(durationMillis = 120),
targetOffsetY = { it / 4 },
) +
scaleOut(
animationSpec = tween(durationMillis = 120),
targetScale = 0.96f,
),
) {
val palette = composerPalette()
Surface(
modifier =
Modifier
.widthIn(max = 280.dp)
.testTag("attachment_tray"),
shape = MaterialTheme.shapes.large,
color = palette.card,
border = BorderStroke(width = 1.dp, color = palette.cardBorder),
) {
Row(
modifier =
Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.spacedBy(4.dp),
) {
AttachmentTrayAction(
icon = Icons.Filled.CameraAlt,
label = stringResource(R.string.chat_attachment_camera),
onClick = {
onDismissRequest()
onCameraTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_camera"),
)
AttachmentTrayAction(
icon = Icons.Filled.PhotoLibrary,
label = stringResource(R.string.chat_attachment_photos),
onClick = {
onDismissRequest()
onImageTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_photos"),
)
AttachmentTrayAction(
icon = Icons.AutoMirrored.Filled.InsertDriveFile,
label = stringResource(R.string.chat_attachment_file),
onClick = {
onDismissRequest()
onFileTap()
},
modifier = Modifier.weight(1f).testTag("attachment_action_file"),
)
}
}
}
}
}
@Composable
private fun AttachmentTrayAction(
icon: ImageVector,
label: String,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Column(
modifier =
modifier
.heightIn(min = 72.dp)
.clickable(onClick = onClick)
.semantics(mergeDescendants = true) {
contentDescription = label
role = Role.Button
}.padding(horizontal = 4.dp, vertical = 6.dp),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center,
) {
Icon(
imageVector = icon,
contentDescription = null,
tint = MaterialTheme.colorScheme.primary,
modifier = Modifier.size(24.dp),
)
Text(
text = label,
style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface,
maxLines = 2,
softWrap = true,
textAlign = androidx.compose.ui.text.style.TextAlign.Center,
)
}
}
internal class AttachmentTrayPositionProvider(
private val marginPx: Int,
) : PopupPositionProvider {
override fun calculatePosition(
anchorBounds: IntRect,
windowSize: IntSize,
layoutDirection: LayoutDirection,
popupContentSize: IntSize,
): IntOffset {
val minX = marginPx
val maxX =
(windowSize.width - popupContentSize.width - marginPx)
.coerceAtLeast(minX)
val x =
if (layoutDirection == LayoutDirection.Ltr) {
anchorBounds.left + marginPx
} else {
anchorBounds.right - popupContentSize.width - marginPx
}
val yAbove = anchorBounds.top - popupContentSize.height - marginPx
val yBelow = anchorBounds.bottom + marginPx
val maxY = (windowSize.height - popupContentSize.height).coerceAtLeast(0)
val y = if (yAbove >= 0) yAbove else yBelow
return IntOffset(
x = x.coerceIn(minX, maxX),
y = y.coerceIn(0, maxY),
)
}
}
@@ -29,7 +29,6 @@ import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
import androidx.compose.material.icons.filled.Close
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
@@ -57,7 +56,6 @@ import androidx.compose.ui.text.style.TextDirection
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import coil3.compose.AsyncImage
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.Attachment
@@ -107,8 +105,8 @@ fun ChatInputBar(
// dropped. Slash commands were already allowed; regular prompts now are too.
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
// Attachment menu state
var showAttachmentMenu by remember { mutableStateOf(false) }
// Attachment tray state
var showAttachmentTray by remember { mutableStateOf(false) }
val palette = composerPalette()
AnimatedVisibility(
@@ -299,7 +297,7 @@ fun ChatInputBar(
isListening = isListening,
canSend = canSend,
onSend = onSend,
onAttachTap = { showAttachmentMenu = true },
onAttachTap = { showAttachmentTray = !showAttachmentTray },
onModelTap = onModelTap,
onReasoningSelected = onReasoningTap,
onMicTap = onMicTap,
@@ -312,52 +310,13 @@ fun ChatInputBar(
onToggleFastMode = onToggleFastMode,
)
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
// Shown overlaid at the toolbar level
DropdownMenu(
expanded = showAttachmentMenu,
onDismissRequest = { showAttachmentMenu = false },
) {
DropdownMenuItem(
text = { Text("Camera") },
onClick = {
showAttachmentMenu = false
onCameraTap()
},
leadingIcon = {
Text(
text = "📷",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("Image") },
onClick = {
showAttachmentMenu = false
onImageTap()
},
leadingIcon = {
Text(
text = "🖼️",
fontSize = 18.sp,
)
},
)
DropdownMenuItem(
text = { Text("File") },
onClick = {
showAttachmentMenu = false
onFileTap()
},
leadingIcon = {
Text(
text = "📄",
fontSize = 18.sp,
)
},
)
}
AttachmentTray(
visible = showAttachmentTray,
onDismissRequest = { showAttachmentTray = false },
onCameraTap = onCameraTap,
onImageTap = onImageTap,
onFileTap = onFileTap,
)
}
}
}
@@ -123,7 +123,7 @@ fun ComposerToolbar(
onClick = onAttachTap,
enabled = isConnected,
colors = flatIconButtonColors(palette),
modifier = Modifier.size(ControlSize),
modifier = Modifier.size(ControlSize).testTag("attachment_button"),
) {
Icon(
imageVector = Icons.Default.Add,