feat(chat): redesign attachment picker tray (#1130)
This commit is contained in:
+96
@@ -3,11 +3,13 @@ package com.m57.hermescontrol.ui.chat.components
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
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.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.platform.LocalLayoutDirection
|
||||||
import androidx.compose.ui.platform.testTag
|
import androidx.compose.ui.platform.testTag
|
||||||
import androidx.compose.ui.test.assertIsDisplayed
|
import androidx.compose.ui.test.assertIsDisplayed
|
||||||
import androidx.compose.ui.test.getUnclippedBoundsInRoot
|
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.test.swipeLeft
|
||||||
import androidx.compose.ui.text.input.TextFieldValue
|
import androidx.compose.ui.text.input.TextFieldValue
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.test.espresso.Espresso.pressBack
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
import androidx.test.filters.MediumTest
|
import androidx.test.filters.MediumTest
|
||||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||||
@@ -44,6 +48,9 @@ class ComposerInteractionTest {
|
|||||||
private var sends = 0
|
private var sends = 0
|
||||||
private var modelTaps = 0
|
private var modelTaps = 0
|
||||||
private var selectedLevel: String? = null
|
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. */
|
/** Renders the real input bar with live text and a mic that toggles like ChatMediaLaunchers. */
|
||||||
private fun setComposer(
|
private fun setComposer(
|
||||||
@@ -51,11 +58,14 @@ class ComposerInteractionTest {
|
|||||||
model: String = "openai/gpt-5.5",
|
model: String = "openai/gpt-5.5",
|
||||||
composerWidth: Dp? = null,
|
composerWidth: Dp? = null,
|
||||||
modelAfterTap: String? = null,
|
modelAfterTap: String? = null,
|
||||||
|
layoutDirection: LayoutDirection = LayoutDirection.Ltr,
|
||||||
) {
|
) {
|
||||||
composeTestRule.setContent {
|
composeTestRule.setContent {
|
||||||
var input by remember { mutableStateOf(TextFieldValue("")) }
|
var input by remember { mutableStateOf(TextFieldValue("")) }
|
||||||
var listening by remember { mutableStateOf(false) }
|
var listening by remember { mutableStateOf(false) }
|
||||||
var currentModel by remember { mutableStateOf(model) }
|
var currentModel by remember { mutableStateOf(model) }
|
||||||
|
|
||||||
|
CompositionLocalProvider(LocalLayoutDirection provides layoutDirection) {
|
||||||
val composer: @Composable () -> Unit = {
|
val composer: @Composable () -> Unit = {
|
||||||
ChatInputBar(
|
ChatInputBar(
|
||||||
inputFieldValue = input,
|
inputFieldValue = input,
|
||||||
@@ -79,6 +89,9 @@ class ComposerInteractionTest {
|
|||||||
modelAfterTap?.let { currentModel = it }
|
modelAfterTap?.let { currentModel = it }
|
||||||
},
|
},
|
||||||
onReasoningTap = { selectedLevel = it },
|
onReasoningTap = { selectedLevel = it },
|
||||||
|
onCameraTap = { cameraTaps++ },
|
||||||
|
onImageTap = { photosTaps++ },
|
||||||
|
onFileTap = { fileTaps++ },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if (composerWidth == null) {
|
if (composerWidth == null) {
|
||||||
@@ -88,6 +101,19 @@ class ComposerInteractionTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
@Test
|
||||||
fun emptyInput_actionButtonStartsDictation() {
|
fun emptyInput_actionButtonStartsDictation() {
|
||||||
@@ -247,4 +273,74 @@ class ComposerInteractionTest {
|
|||||||
assertEquals("model selection should still invoke the picker", 1, modelTaps)
|
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()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
import androidx.compose.material.icons.automirrored.filled.InsertDriveFile
|
||||||
import androidx.compose.material.icons.filled.Close
|
import androidx.compose.material.icons.filled.Close
|
||||||
import androidx.compose.material3.DropdownMenu
|
|
||||||
import androidx.compose.material3.DropdownMenuItem
|
import androidx.compose.material3.DropdownMenuItem
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.IconButton
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.LayoutDirection
|
import androidx.compose.ui.unit.LayoutDirection
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import com.m57.hermescontrol.R
|
import com.m57.hermescontrol.R
|
||||||
import com.m57.hermescontrol.data.model.Attachment
|
import com.m57.hermescontrol.data.model.Attachment
|
||||||
@@ -107,8 +105,8 @@ fun ChatInputBar(
|
|||||||
// dropped. Slash commands were already allowed; regular prompts now are too.
|
// dropped. Slash commands were already allowed; regular prompts now are too.
|
||||||
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
|
val canSend = ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected)
|
||||||
|
|
||||||
// Attachment menu state
|
// Attachment tray state
|
||||||
var showAttachmentMenu by remember { mutableStateOf(false) }
|
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||||
val palette = composerPalette()
|
val palette = composerPalette()
|
||||||
|
|
||||||
AnimatedVisibility(
|
AnimatedVisibility(
|
||||||
@@ -299,7 +297,7 @@ fun ChatInputBar(
|
|||||||
isListening = isListening,
|
isListening = isListening,
|
||||||
canSend = canSend,
|
canSend = canSend,
|
||||||
onSend = onSend,
|
onSend = onSend,
|
||||||
onAttachTap = { showAttachmentMenu = true },
|
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||||
onModelTap = onModelTap,
|
onModelTap = onModelTap,
|
||||||
onReasoningSelected = onReasoningTap,
|
onReasoningSelected = onReasoningTap,
|
||||||
onMicTap = onMicTap,
|
onMicTap = onMicTap,
|
||||||
@@ -312,52 +310,13 @@ fun ChatInputBar(
|
|||||||
onToggleFastMode = onToggleFastMode,
|
onToggleFastMode = onToggleFastMode,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Attachment dropdown (anchored to the attach button in ComposerToolbar)
|
AttachmentTray(
|
||||||
// Shown overlaid at the toolbar level
|
visible = showAttachmentTray,
|
||||||
DropdownMenu(
|
onDismissRequest = { showAttachmentTray = false },
|
||||||
expanded = showAttachmentMenu,
|
onCameraTap = onCameraTap,
|
||||||
onDismissRequest = { showAttachmentMenu = false },
|
onImageTap = onImageTap,
|
||||||
) {
|
onFileTap = onFileTap,
|
||||||
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,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ fun ComposerToolbar(
|
|||||||
onClick = onAttachTap,
|
onClick = onAttachTap,
|
||||||
enabled = isConnected,
|
enabled = isConnected,
|
||||||
colors = flatIconButtonColors(palette),
|
colors = flatIconButtonColors(palette),
|
||||||
modifier = Modifier.size(ControlSize),
|
modifier = Modifier.size(ControlSize).testTag("attachment_button"),
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
imageVector = Icons.Default.Add,
|
imageVector = Icons.Default.Add,
|
||||||
|
|||||||
@@ -1084,6 +1084,9 @@
|
|||||||
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
||||||
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
<string name="channels_search_placeholder">البحث في القنوات…</string>
|
||||||
<string name="chat_attach_file">إرفاق ملف</string>
|
<string name="chat_attach_file">إرفاق ملف</string>
|
||||||
|
<string name="chat_attachment_camera">الكاميرا</string>
|
||||||
|
<string name="chat_attachment_photos">الصور</string>
|
||||||
|
<string name="chat_attachment_file">ملف</string>
|
||||||
<string name="chat_fast_mode_label">سريع</string>
|
<string name="chat_fast_mode_label">سريع</string>
|
||||||
<string name="chat_fast_mode_desc">الوضع السريع</string>
|
<string name="chat_fast_mode_desc">الوضع السريع</string>
|
||||||
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
|
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
|
||||||
|
|||||||
@@ -928,6 +928,9 @@
|
|||||||
<string name="subagent_status_cancelled">취소됨</string>
|
<string name="subagent_status_cancelled">취소됨</string>
|
||||||
<string name="subagent_status_failed">실패</string>
|
<string name="subagent_status_failed">실패</string>
|
||||||
<string name="subagent_duration">%1$.1f초</string>
|
<string name="subagent_duration">%1$.1f초</string>
|
||||||
|
<string name="chat_attachment_camera">카메라</string>
|
||||||
|
<string name="chat_attachment_photos">사진</string>
|
||||||
|
<string name="chat_attachment_file">파일</string>
|
||||||
<string name="chat_fast_mode_label">빠름</string>
|
<string name="chat_fast_mode_label">빠름</string>
|
||||||
<string name="chat_fast_mode_desc">빠른 모드</string>
|
<string name="chat_fast_mode_desc">빠른 모드</string>
|
||||||
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
|
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
|
||||||
|
|||||||
@@ -1067,6 +1067,9 @@
|
|||||||
<string name="channels_expiry_format">过期时间:%1$s</string>
|
<string name="channels_expiry_format">过期时间:%1$s</string>
|
||||||
<string name="channels_search_placeholder">搜索渠道…</string>
|
<string name="channels_search_placeholder">搜索渠道…</string>
|
||||||
<string name="chat_attach_file">附加文件</string>
|
<string name="chat_attach_file">附加文件</string>
|
||||||
|
<string name="chat_attachment_camera">相机</string>
|
||||||
|
<string name="chat_attachment_photos">照片</string>
|
||||||
|
<string name="chat_attachment_file">文件</string>
|
||||||
<string name="chat_fast_mode_label">快速</string>
|
<string name="chat_fast_mode_label">快速</string>
|
||||||
<string name="chat_fast_mode_desc">快速模式</string>
|
<string name="chat_fast_mode_desc">快速模式</string>
|
||||||
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
|
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
|
||||||
|
|||||||
@@ -1244,6 +1244,9 @@
|
|||||||
<string name="channels_expiry_format">Expires: %1$s</string>
|
<string name="channels_expiry_format">Expires: %1$s</string>
|
||||||
<string name="channels_search_placeholder">Search channels…</string>
|
<string name="channels_search_placeholder">Search channels…</string>
|
||||||
<string name="chat_attach_file">Attach file</string>
|
<string name="chat_attach_file">Attach file</string>
|
||||||
|
<string name="chat_attachment_camera">Camera</string>
|
||||||
|
<string name="chat_attachment_photos">Photos</string>
|
||||||
|
<string name="chat_attachment_file">File</string>
|
||||||
<string name="chat_fast_mode_label">Fast</string>
|
<string name="chat_fast_mode_label">Fast</string>
|
||||||
<string name="chat_fast_mode_desc">Fast mode</string>
|
<string name="chat_fast_mode_desc">Fast mode</string>
|
||||||
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
|
<string name="chat_fast_mode_unavailable">Fast mode unavailable for this model</string>
|
||||||
|
|||||||
+40
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user