From afffcd95e889be6c040bed9846234f873fbb567c Mon Sep 17 00:00:00 2001 From: M57 <41645758+Hy4ri@users.noreply.github.com> Date: Tue, 15 Sep 2026 21:56:06 +0300 Subject: [PATCH] feat(chat): redesign attachment picker tray (#1130) --- .../components/ComposerInteractionTest.kt | 154 +++++++++--- .../ui/chat/components/AttachmentTray.kt | 229 ++++++++++++++++++ .../ui/chat/components/ChatComposer.kt | 61 +---- .../ui/chat/components/ComposerToolbar.kt | 2 +- app/src/main/res/values-ar/strings.xml | 3 + app/src/main/res/values-ko/strings.xml | 3 + app/src/main/res/values-zh/strings.xml | 3 + app/src/main/res/values/strings.xml | 3 + .../AttachmentTrayPositionProviderTest.kt | 40 +++ 9 files changed, 417 insertions(+), 81 deletions(-) create mode 100644 app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt create mode 100644 app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt index 546b767d..19530c0d 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/components/ComposerInteractionTest.kt @@ -3,11 +3,13 @@ package com.m57.hermescontrol.ui.chat.components import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalLayoutDirection import androidx.compose.ui.platform.testTag import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.getUnclippedBoundsInRoot @@ -20,7 +22,9 @@ import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeLeft import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection import androidx.compose.ui.unit.dp +import androidx.test.espresso.Espresso.pressBack import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest import com.m57.hermescontrol.data.ws.CommandCatalog @@ -44,6 +48,9 @@ class ComposerInteractionTest { private var sends = 0 private var modelTaps = 0 private var selectedLevel: String? = null + private var cameraTaps = 0 + private var photosTaps = 0 + private var fileTaps = 0 /** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */ private fun setComposer( @@ -51,44 +58,63 @@ class ComposerInteractionTest { model: String = "openai/gpt-5.5", composerWidth: Dp? = null, modelAfterTap: String? = null, + layoutDirection: LayoutDirection = LayoutDirection.Ltr, ) { composeTestRule.setContent { var input by remember { mutableStateOf(TextFieldValue("")) } var listening by remember { mutableStateOf(false) } var currentModel by remember { mutableStateOf(model) } - val composer: @Composable () -> Unit = { - ChatInputBar( - inputFieldValue = input, - onInputChange = { input = it }, - onSend = { - sends++ - input = TextFieldValue("") - }, - onMicTap = { - micTaps++ - listening = !listening - }, - isListening = listening, - isAgentTyping = false, - isConnected = true, - commandCatalog = CommandCatalog(), - currentSessionModel = currentModel, - reasoningLevel = reasoningLevel, - onModelTap = { - modelTaps++ - modelAfterTap?.let { currentModel = it } - }, - onReasoningTap = { selectedLevel = it }, - ) - } - if (composerWidth == null) { - composer() - } else { - Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } + + CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) { + val composer: @Composable () -> Unit = { + ChatInputBar( + inputFieldValue = input, + onInputChange = { input = it }, + onSend = { + sends++ + input = TextFieldValue("") + }, + onMicTap = { + micTaps++ + listening = !listening + }, + isListening = listening, + isAgentTyping = false, + isConnected = true, + commandCatalog = CommandCatalog(), + currentSessionModel = currentModel, + reasoningLevel = reasoningLevel, + onModelTap = { + modelTaps++ + modelAfterTap?.let { currentModel = it } + }, + onReasoningTap = { selectedLevel = it }, + onCameraTap = { cameraTaps++ }, + onImageTap = { photosTaps++ }, + onFileTap = { fileTaps++ }, + ) + } + if (composerWidth == null) { + composer() + } else { + Box(Modifier.width(composerWidth).testTag("composer_toolbar")) { composer() } + } } } } + private fun openAttachmentTray() { + composeTestRule.onNodeWithTag("attachment_button").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.onNodeWithTag("attachment_tray").assertIsDisplayed() + } + + private fun closeAttachmentTray() { + composeTestRule.onNodeWithTag("attachment_button").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } + @Test fun emptyInput_actionButtonStartsDictation() { setComposer() @@ -247,4 +273,74 @@ class ComposerInteractionTest { assertEquals("model selection should still invoke the picker", 1, modelTaps) } } + + @Test + fun attachmentButton_togglesHorizontalTray() { + setComposer() + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + + closeAttachmentTray() + } + + @Test + fun attachmentTray_shrinksWithinConstrainedWidth() { + setComposer(composerWidth = 180.dp) + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + } + + @Test + fun attachmentTray_rtlKeepsActionsAccessible() { + setComposer(layoutDirection = LayoutDirection.Rtl) + + openAttachmentTray() + composeTestRule.onNodeWithText("Camera").assertIsDisplayed() + composeTestRule.onNodeWithText("Photos").assertIsDisplayed() + composeTestRule.onNodeWithText("File").assertIsDisplayed() + } + + @Test + fun attachmentTray_actionsDispatchAndCloseTray() { + setComposer() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_camera").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { + assertEquals("Camera must dispatch its callback", 1, cameraTaps) + assertEquals("Camera must not dispatch Photos", 0, photosTaps) + assertEquals("Camera must not dispatch File", 0, fileTaps) + } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_photos").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { assertEquals("Photos must dispatch its callback", 1, photosTaps) } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + + openAttachmentTray() + composeTestRule.onNodeWithTag("attachment_action_file").performClick() + composeTestRule.mainClock.advanceTimeBy(300) + composeTestRule.runOnIdle { assertEquals("File must dispatch its callback", 1, fileTaps) } + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } + + @Test + fun attachmentTray_dismissesOnBackPress() { + setComposer() + + openAttachmentTray() + pressBack() + composeTestRule.mainClock.advanceTimeBy(300) + + composeTestRule.onNodeWithTag("attachment_tray").assertDoesNotExist() + } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt new file mode 100644 index 00000000..60eb3d0d --- /dev/null +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/AttachmentTray.kt @@ -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), + ) + } +} diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt index 03e9589f..c3d1a9b0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ChatComposer.kt @@ -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, + ) } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 1d0a88a6..aab2a2f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -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, diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 58f81a01..65666052 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -1084,6 +1084,9 @@ ينتهي في: %1$s البحث في القنوات… إرفاق ملف +الكاميرا +الصور +ملف سريع الوضع السريع الوضع السريع غير مدعوم لهذا النموذج diff --git a/app/src/main/res/values-ko/strings.xml b/app/src/main/res/values-ko/strings.xml index 4086ba14..1b60650f 100644 --- a/app/src/main/res/values-ko/strings.xml +++ b/app/src/main/res/values-ko/strings.xml @@ -928,6 +928,9 @@ 취소됨 실패 %1$.1f초 + 카메라 + 사진 + 파일 빠름 빠른 모드 이 모델에서는 빠른 모드를 사용할 수 없습니다 diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml index 93c06bd3..7f2a0873 100644 --- a/app/src/main/res/values-zh/strings.xml +++ b/app/src/main/res/values-zh/strings.xml @@ -1067,6 +1067,9 @@ 过期时间:%1$s 搜索渠道… 附加文件 + 相机 + 照片 + 文件 快速 快速模式 此模型不支持快速模式 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3d89cdd..d7308260 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1244,6 +1244,9 @@ Expires: %1$s Search channels… Attach file + Camera + Photos + File Fast Fast mode Fast mode unavailable for this model diff --git a/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt new file mode 100644 index 00000000..65956dc2 --- /dev/null +++ b/app/src/test/java/com/m57/hermescontrol/ui/chat/components/AttachmentTrayPositionProviderTest.kt @@ -0,0 +1,40 @@ +package com.m57.hermescontrol.ui.chat.components + +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 org.junit.Assert.assertEquals +import org.junit.Test + +class AttachmentTrayPositionProviderTest { + private val provider = AttachmentTrayPositionProvider(marginPx = 16) + private val windowSize = IntSize(width = 500, height = 1_000) + private val popupSize = IntSize(width = 280, height = 100) + + @Test + fun ltr_clampsToVisibleWindowWithHorizontalMargin() { + val position = + provider.calculatePosition( + anchorBounds = IntRect(left = 400, top = 700, right = 500, bottom = 900), + windowSize = windowSize, + layoutDirection = LayoutDirection.Ltr, + popupContentSize = popupSize, + ) + + assertEquals(IntOffset(x = 204, y = 584), position) + } + + @Test + fun rtl_clampsToVisibleWindowWithHorizontalMargin() { + val position = + provider.calculatePosition( + anchorBounds = IntRect(left = 0, top = 700, right = 100, bottom = 900), + windowSize = windowSize, + layoutDirection = LayoutDirection.Rtl, + popupContentSize = popupSize, + ) + + assertEquals(IntOffset(x = 16, y = 584), position) + } +}