feat(chat): redesign attachment picker tray (#1130)
This commit is contained in:
9 files changed
+417
-81
No files matched your search
+125
-29
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -1084,6 +1084,9 @@
|
||||
<string name="channels_expiry_format">ينتهي في: %1$s</string>
|
||||
<string name="channels_search_placeholder">البحث في القنوات…</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_desc">الوضع السريع</string>
|
||||
<string name="chat_fast_mode_unavailable">الوضع السريع غير مدعوم لهذا النموذج</string>
|
||||
|
||||
@@ -928,6 +928,9 @@
|
||||
<string name="subagent_status_cancelled">취소됨</string>
|
||||
<string name="subagent_status_failed">실패</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_desc">빠른 모드</string>
|
||||
<string name="chat_fast_mode_unavailable">이 모델에서는 빠른 모드를 사용할 수 없습니다</string>
|
||||
|
||||
@@ -1067,6 +1067,9 @@
|
||||
<string name="channels_expiry_format">过期时间:%1$s</string>
|
||||
<string name="channels_search_placeholder">搜索渠道…</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_desc">快速模式</string>
|
||||
<string name="chat_fast_mode_unavailable">此模型不支持快速模式</string>
|
||||
|
||||
@@ -1244,6 +1244,9 @@
|
||||
<string name="channels_expiry_format">Expires: %1$s</string>
|
||||
<string name="channels_search_placeholder">Search channels…</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_desc">Fast mode</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