feat(chat): 分享确认面板的正文预览可以展开 / 收起
问题:W54D 把正文那一窗改成「三行 + 内部滚动」之后「读不到整段正文」是解决了,但一次只看得见 三行。主人拍板「可以滚动 可以展开」——两个都要,所以这一轮补上后半。 改法(只动 ShareConfirmPanel.kt 与它的测试,ChatScreen / ChatViewModel 与接线一字未碰): - 正文卡片里加一枚「展开 / 收起」(`Text` + `clickable`,不用 `TextButton`:按钮自带高度会把 这一行撑高一截)。它挂在「分享的文字」那一行的右端,不挂在正文末尾——展开后正文可能好几屏, 末尾那枚「收起」要滚到底才找得到。 - 收起态一个字没变:三行窗(SHARE_PREVIEW_TEXT_LINES = 3)+ 内部滚动。 - 展开态:正文那一窗撤掉自己的上限(`heightIn(max = Dp.Infinity)`,正是首帧那个状态),整段交给 外层预览区滚;外层上限由 150dp 抬到 240dp(SHARE_PREVIEW_EXPANDED_MAX_HEIGHT)。展开态**只留 这一层**滚动是刻意的:两层都设上限时手势先被内层吃掉、内层滚到底才轮到外层,两层上界还互相 牵制,最容易出的错就是「明明展开了还剩半截读不到」。 - 控件**只在正文排下来超过三行时出现**。判据是行数 > SHARE_PREVIEW_TEXT_LINES,不是 `hasVisualOverflow`:这一窗的高度不受限(`verticalScroll` 给子项的就是无上限约束),`Text` 自己 永远报「没有溢出」,拿它当判据会在最长的那种正文上漏掉控件;行数则与上限无关,首帧就可用。 - 展开态为什么是 240dp:这个上限管的是**整个预览区**,下面还压着描述输入框(最多 4 行)与两枚 按钮,面板贴着屏幕底、键盘一弹可用高度只剩半屏上下。150 → 240 在默认字号档上把正文窗从三行抬到 十行上下;再往上(例如整屏的六成)先被顶掉的会是按钮。⚠️ 这个数**没在真机上量过**,是按这笔账 取的保守值,观感由主人验收,要调只改这一个常量。 - 两条判据都做成纯函数(`sharePreviewTextOverflowsWindow` / `sharePreviewMaxHeight`),调用点只读 结论,单测钉的就是它们。 上一批留下的「首帧回流会不会看得出来」疑点,复核结论:**留着,且确认它看不见**(理由已写进 `ShareConfirmBody` 的 KDoc):① 首帧那一下撑不动任何别的东西——正文块是预览区里的最后一个元素, 可视像素只由外层那层独立的上限裁剪决定,上面图片/文件的位置一分不动;② 展开态本来就没有这一层 上限,不存在「量完再回落」;而展开 / 收起那一帧的尺寸变化是用户动作的**目的**,不是回流。真正多 出来的是控件本身(第二帧才出现,因为行数是排完才知道的),它与标签同用 `footnote2`、行高不变, 也顶不动任何东西。组合期 `rememberTextMeasurer` 那条路要外包 `BoxWithConstraints` 拿宽度、还要 自己复现字号与字体缩放,等于把排版做两遍——为一个看不见的跳动换掉结构不划算,不做。 已知取舍(如实记下,没修):转屏(Activity 重建)后展开态回到收起。它现在活在 `remember(preview.text)` 里,跨转屏要活下来只有两条路——进 `ChatUiState`(W54C 给「已取走的分享」 与描述走的就是那条路,但那是 ChatScreen / ChatViewModel 的地盘,本批红线不许碰),或 `rememberSaveable`(本仓一次都没用过的新写法,且弹层窗口带不带 saveable registry 没在真机上验过)。 两条都没走,留一条 ⚠️ 在代码里 + 留给主人定。 测试:新增 3 条(纯函数判据)——「什么时候该出现展开控件」(0/1/3 行不给、4 行起给,并接一份 40 行的真分享走一遍)、「展开态给多高」(150 / 240 两个字面量 + 展开严格更高 + 不超过收起态的 两倍)、「两句标签不同且非空」。展开与滚动本身单测够不着(本模块没有 Compose UI 测试的依赖), 如实说明;既有断言一条未放松。 验证:`./gradlew.bat assembleDebug testDebugUnitTest --tests "*ShareConfirm*"` → BUILD SUCCESSFUL (ShareConfirmTest 15 条 = 既有 12 + 新增 3、ShareConfirmDraftTest 3 条,0 失败 / 0 错误); 全量 `./gradlew.bat testDebugUnitTest` → BUILD SUCCESSFUL,112 suite / 1361 tests / 0 失败。
This commit is contained in:
1 parent
9ff2de3a1d
commit
14955ef6c0
2 files changed
+245
-21
No files matched your search
@@ -1,6 +1,7 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@@ -249,7 +250,8 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
* 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高,
|
||||
* 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。
|
||||
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
|
||||
* 输入框与按钮原地不动。
|
||||
* 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
|
||||
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
|
||||
*
|
||||
* ## 正文给三行,但三行之外是「滚」不是「省略号」
|
||||
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
|
||||
@@ -260,13 +262,32 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
|
||||
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
|
||||
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
|
||||
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
|
||||
*
|
||||
* ## 三行读不下时,再给一次「展开」
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
|
||||
* (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是
|
||||
* [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**——
|
||||
* 短正文上摆一枚点了没反应的「展开」比没有更糟。
|
||||
*
|
||||
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
|
||||
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
// 展开是用户对**这一段正文**的选择,键在 `preview.text` 上:换一份分享就回到默认的收起,同一份
|
||||
// 分享里重组(写描述、列表刷新…)保持用户选过的样子。
|
||||
//
|
||||
// ⚠️ 转屏(Activity 重建)会回到收起:跨转屏要活下来就得进 `ChatUiState`——W54C 给「已取走的
|
||||
// 分享」与描述走的就是那条路,而那是 `ChatScreen` / `ChatViewModel` 的地盘(本批不许碰)。
|
||||
// `rememberSaveable` 是本仓一次都没用过的新写法,而且弹层窗口有没有带上 saveable registry 没在
|
||||
// 真机上验过,不在这里试——如实留给主人定。
|
||||
var textExpanded by remember(preview.text) { mutableStateOf(false) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = SHARE_PREVIEW_MAX_HEIGHT)
|
||||
.heightIn(max = sharePreviewMaxHeight(textExpanded))
|
||||
.verticalScroll(rememberScrollState()),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -295,6 +316,9 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
|
||||
}
|
||||
if (preview.hasText) {
|
||||
// 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文
|
||||
// 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。
|
||||
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@@ -302,19 +326,68 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "分享的文字",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "分享的文字",
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
// 标签是这一行唯一的权重子项,`fill = false` 让这五个字只占自己需要的宽度;
|
||||
// 真正保证展开控件不被挤掉的是 Row 的测量顺序——**非权重子项(控件与它前面
|
||||
// 的间距)先量**,权重只分配剩下的空间(同 `SessionDrawerContent` 里那一行)。
|
||||
modifier = Modifier.weight(1f, fill = false),
|
||||
)
|
||||
if (bodyOverflows) {
|
||||
// 控件挂在**标签这一行**,不挂在正文末尾:展开后正文可能有好几屏,末尾那枚
|
||||
// 「收起」就成了要滚到底才找得到的东西。
|
||||
Spacer(Modifier.width(8.dp))
|
||||
ShareConfirmExpandToggle(
|
||||
expanded = textExpanded,
|
||||
onToggle = { textExpanded = !textExpanded },
|
||||
)
|
||||
}
|
||||
}
|
||||
ShareConfirmBody(
|
||||
text = preview.text,
|
||||
expanded = textExpanded,
|
||||
onOverflowChange = { bodyOverflows = it },
|
||||
)
|
||||
ShareConfirmBody(preview.text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews])。
|
||||
* 「展开 / 收起」那一枚(见 [ShareConfirmPreviews])。
|
||||
*
|
||||
* 为什么是两个字而不是箭头图标:基础图标那一套(`MiuixIcons.Basic.*`)里没有展开 / 收起这一类字形
|
||||
* (只有 `Close` / `Search` / `SearchCleanup` / `Sidebar` / `Check` / `ArrowRight` / `ArrowUpDown`
|
||||
* 七枚),而这两句话本身就说清了点下去会怎样。另一处折叠入口——`AdminOverviewScreen` 的路径组头
|
||||
* ——用的是扩展图标里的 `ChevronForward` 加旋转,那是列表行里的形态;这里是一张阅读用的卡片,
|
||||
* 一句话更省地方,也更好认。
|
||||
*
|
||||
* 用 `Text` + `clickable` 而不是 `TextButton`:后者自带按钮高度,会把这一行(以及整块预览)撑高
|
||||
* 一截——同 `MessageBubble` 里那枚「更多」的取舍。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmExpandToggle(expanded: Boolean, onToggle: () -> Unit) {
|
||||
Text(
|
||||
text = if (expanded) SHARE_PREVIEW_COLLAPSE_LABEL else SHARE_PREVIEW_EXPAND_LABEL,
|
||||
style = MiuixTheme.textStyles.footnote2,
|
||||
color = MiuixTheme.colorScheme.primary,
|
||||
modifier = Modifier
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onToggle)
|
||||
// 内边距写在 `clickable` **后面**:命中的就是这块带内边距的区域——只包住字身的触点太小。
|
||||
.padding(horizontal = 6.dp, vertical = 2.dp),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 正文那一窗:**高度恒为三行,再长就在这一窗里滚**(W54D 主人拍板,见 [ShareConfirmPreviews]);
|
||||
* 用户点了「展开」之后这一窗撤掉自己的上限,整段正文交给外层预览区那一层滚(W56)。
|
||||
*
|
||||
* ## 为什么量行高,而不是写一个 dp 常量
|
||||
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
|
||||
@@ -324,27 +397,52 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
|
||||
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
|
||||
*
|
||||
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
|
||||
* 与样式都没变,行位置与首帧一致,量出来的值也一样。
|
||||
* 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
|
||||
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。
|
||||
*
|
||||
* ⚠️ 代价是**首帧先按完整正文排一次**、下一帧才收到三行:正文很长时,这一窗理论上会有一帧高出一截、
|
||||
* 然后回落。另一条路——写死一个 dp 上限——没有这一帧,但在非默认字号档上必然切字,两害相权取了
|
||||
* 「量」。这一帧在实际渲染里看不看得见(弹层这时正在进场,可能被动画盖住)**没有在真机上验过**,
|
||||
* 留给主人真机验收。
|
||||
* ## 展开态为什么干脆不留这一层上限
|
||||
* 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
|
||||
* (手指先被内层吃掉、内层滚到底才轮到外层),而两层的上界还互相牵制——最容易出的错就是「明明展开
|
||||
* 了、还剩半截读不到」。所以展开态**只留外层那一层**:预览区的上限抬到
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
|
||||
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
|
||||
*
|
||||
* ## 首帧的那次回流:留着,且确认它看不见(W56 复核)
|
||||
* 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念:
|
||||
* 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由:
|
||||
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
|
||||
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
|
||||
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
|
||||
* 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步;
|
||||
* 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的
|
||||
* 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那
|
||||
* 两句话用的是同一个 `footnote2`,行高不变。
|
||||
*
|
||||
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
|
||||
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
|
||||
* 为一个看不见的跳动换掉结构不划算,所以不做。
|
||||
*
|
||||
* ## 两层滚动怎么分工
|
||||
* 这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块合起来的高度
|
||||
* 上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来就走嵌套滚动,
|
||||
* 不用额外接线。
|
||||
* 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
|
||||
* 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
|
||||
* 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]),
|
||||
* 手势整个交给外层。
|
||||
*/
|
||||
@Composable
|
||||
private fun ShareConfirmBody(text: String) {
|
||||
private fun ShareConfirmBody(
|
||||
text: String,
|
||||
expanded: Boolean,
|
||||
onOverflowChange: (Boolean) -> Unit,
|
||||
) {
|
||||
val density = LocalDensity.current
|
||||
var cap by remember(text) { mutableStateOf(Dp.Infinity) }
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.heightIn(max = cap)
|
||||
// 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它),
|
||||
// 所以展开态不多一条代码路径,也不会比首帧更糟。
|
||||
.heightIn(max = if (expanded) Dp.Infinity else cap)
|
||||
.verticalScroll(rememberScrollState()),
|
||||
) {
|
||||
Text(
|
||||
@@ -356,22 +454,79 @@ private fun ShareConfirmBody(text: String) {
|
||||
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
|
||||
cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
|
||||
}
|
||||
// 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以
|
||||
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
|
||||
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
/** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
|
||||
internal val SHARE_PREVIEW_MAX_HEIGHT = 150.dp
|
||||
|
||||
/**
|
||||
* 预览区在**展开态**的高度上限(用户点了「展开」之后,见 [ShareConfirmPreviews])。
|
||||
*
|
||||
* 为什么要抬:展开是**用户自己的一个动作**,要的就是「一次多看几行」。上限不动的话这一窗永远只有
|
||||
* 三行高,点开等于没点——正文那一窗顶到 [SHARE_PREVIEW_MAX_HEIGHT] 也只是把三行换成十来行里的一
|
||||
* 小截。所以展开态必须真的给回高度。
|
||||
*
|
||||
* 为什么是 240dp 而不是更大:这个上限管的是**整个预览区**,而它下面还压着描述输入框(最多 4 行)
|
||||
* 与两枚按钮,面板又贴着屏幕底(`BottomCenter`)、键盘一弹可用高度只剩半屏上下。150 → 240 在默认
|
||||
* 字号档上把正文窗从三行抬到十行左右(`footnote1` 13sp,行高由系统字体决定、随机型与大字体设置一起
|
||||
* 变),加上标题 / 输入框 / 按钮之后整块仍在半屏出头;再往上(例如给到整屏的六成)先被顶掉的会是
|
||||
* 两枚按钮。
|
||||
*
|
||||
* ⚠️ 这个数**没有在真机上量过**,是按上面那笔账取的保守值,观感由主人验收。要调只改这一处:
|
||||
* 预览区那一层的高度上限取的就是 [sharePreviewMaxHeight] 这一个出口。
|
||||
*/
|
||||
internal val SHARE_PREVIEW_EXPANDED_MAX_HEIGHT = 240.dp
|
||||
|
||||
/**
|
||||
* 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
|
||||
*
|
||||
* 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有
|
||||
* 东西可展开。
|
||||
*
|
||||
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
|
||||
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
|
||||
*/
|
||||
internal const val SHARE_PREVIEW_TEXT_LINES = 3
|
||||
|
||||
/**
|
||||
* 展开控件上那两句话:点它就在这两句之间切换。
|
||||
*
|
||||
* 两句都必须是**动作**(「展开」/「收起」),不能是「更多」/「更少」——后者说的是内容有多少,读的人
|
||||
* 不知道点下去会发生什么。
|
||||
*/
|
||||
internal const val SHARE_PREVIEW_EXPAND_LABEL = "展开"
|
||||
internal const val SHARE_PREVIEW_COLLAPSE_LABEL = "收起"
|
||||
|
||||
/**
|
||||
* 正文**排下来**超过三行窗口了吗(**纯函数**,可单测)——也就是「要不要给出展开控件」。
|
||||
*
|
||||
* 判据是**行数**,不是 `hasVisualOverflow`:正文这一窗的裁剪落在它外面那层 `Column` 的
|
||||
* `heightIn(max = cap)` 上,而这一窗自己的排版宽度是满宽、高度不受限(`verticalScroll` 给子项的
|
||||
* 就是无上限约束),所以 `Text` 自己**永远**报「没有溢出」——拿它当判据会在最长的那种正文上漏掉
|
||||
* 控件。行数则与上限无关:只要排下来比窗口多一行,第四行就一定在窗口之外,展开就有东西可看。
|
||||
*
|
||||
* 边界是**严格大于**:正好三行时整段都在窗里(`getLineBottom(2)` 就是这一窗的底),此时给一枚
|
||||
* 「展开」是点了没有反应的假控件。
|
||||
*/
|
||||
internal fun sharePreviewTextOverflowsWindow(lineCount: Int): Boolean =
|
||||
lineCount > SHARE_PREVIEW_TEXT_LINES
|
||||
|
||||
/**
|
||||
* 预览区当前该给多高(**纯函数**,可单测):收起态 [SHARE_PREVIEW_MAX_HEIGHT]、展开态
|
||||
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
|
||||
*
|
||||
* 把「哪个状态用哪个上限」收在这一处,调用点([ShareConfirmPreviews])只读结论:单测钉住的就是这条
|
||||
* 判据本身——两个状态与两个上限的对应关系要是被改反了(例如展开反而变矮),会在这里被抓住。
|
||||
*/
|
||||
internal fun sharePreviewMaxHeight(expanded: Boolean): Dp =
|
||||
if (expanded) SHARE_PREVIEW_EXPANDED_MAX_HEIGHT else SHARE_PREVIEW_MAX_HEIGHT
|
||||
|
||||
/**
|
||||
* 分享进来的一份文件 ⇒ 待发附件。
|
||||
*
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import androidx.compose.ui.unit.dp
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNotEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
import top.yeij.cyrene.domain.share.SharedContent
|
||||
@@ -13,7 +15,7 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
* 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。
|
||||
* 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**发送时正文
|
||||
* 是哪几段拼起来的**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到
|
||||
* 的是不是他要发的那一份」。本测试钉住四件事:
|
||||
* 的是不是他要发的那一份」。本测试钉住四件事(W56 加的两条判据另见文末那一节):
|
||||
*
|
||||
* 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序);
|
||||
* 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说;
|
||||
@@ -39,6 +41,14 @@ import top.yeij.cyrene.domain.share.SharedDocument
|
||||
* 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少)
|
||||
* 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与
|
||||
* `the preview window stays three lines tall`。
|
||||
*
|
||||
* ## W56 又加上了什么(主人拍板:「可以滚动 可以展开」,两个都要)
|
||||
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;所以正文排下来超过三行时([sharePreviewTextOverflowsWindow])
|
||||
* 卡片右上一枚「展开」把这一窗放到 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。
|
||||
* **展开与滚动本身仍然是渲染层的事、单测够不着**,所以这里钉的同样是它们的两个前提:**什么时候该
|
||||
* 出现那枚控件**、**展开态给多高**——两条判据都做成了纯函数([sharePreviewTextOverflowsWindow] /
|
||||
* [sharePreviewMaxHeight]),见 `the expand control shows up only when the body does not fit the
|
||||
* window` 与 `expanding gives the preview more room, still within a bound`。
|
||||
*/
|
||||
class ShareConfirmTest {
|
||||
|
||||
@@ -247,4 +257,63 @@ class ShareConfirmTest {
|
||||
// 当成「用户把正文删光了」而变灰)。它单独钉一条字面量,免得将来有人把它当成回归改回去。
|
||||
assertTrue(shareConfirmCanSend(SharedContent(text = "看这个"), ""))
|
||||
}
|
||||
|
||||
// ——— W56:「展开 / 收起」(主人拍板:滚动与展开**两个都要**)———
|
||||
// 展开与滚动本身在单测里断言不了(本模块没有 Compose UI 测试的依赖),能钉住的是它们的两个前提:
|
||||
// 那枚控件什么时候出现、展开态给多高。两条判据都在 ShareConfirmPanel.kt 里做成了纯函数。
|
||||
|
||||
@Test
|
||||
fun `the expand control shows up only when the body does not fit the window`() {
|
||||
// 正好三行 = 整段都在窗里(这一窗的底就是第三行的底边):此时给一枚「展开」就是主人说的
|
||||
// 「点了没反应的展开」。多一行就是第四行落在窗外,展开确有东西可看。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(0))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(1))
|
||||
assertFalse(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(SHARE_PREVIEW_TEXT_LINES + 1))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(40))
|
||||
|
||||
// 判据跟着那一窗的行数走(上面用的是常量),所以再用主人拍的那个数字面量钉一遍「这条线是
|
||||
// 3」——两条一起才既钉住行为、又钉住那个数(同 `the preview window stays three lines tall`
|
||||
// 的口径)。
|
||||
assertFalse(sharePreviewTextOverflowsWindow(3))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(4))
|
||||
|
||||
// 接一份真分享走一遍:正文里每有一个换行,排下来就至少多一行,所以 40 行的那段一定超过窗口
|
||||
// (排版后的行数只会比 `lines().size` 更多——单行太长还会折行)。
|
||||
val preview = shareConfirmPreview(SharedContent(text = (1..40).joinToString("\n") { "第 $it 行" }))
|
||||
assertTrue(sharePreviewTextOverflowsWindow(preview.text.lines().size))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `expanding gives the preview more room, still within a bound`() {
|
||||
// 「哪个状态用哪个上限」只有这一处出处(调用点只读结论),所以两个数都钉成字面量:改数就是
|
||||
// 改面板的高度预算,必须是有意为之。
|
||||
assertEquals(150.dp, sharePreviewMaxHeight(expanded = false))
|
||||
assertEquals(240.dp, sharePreviewMaxHeight(expanded = true))
|
||||
assertEquals(SHARE_PREVIEW_MAX_HEIGHT, sharePreviewMaxHeight(expanded = false))
|
||||
assertEquals(SHARE_PREVIEW_EXPANDED_MAX_HEIGHT, sharePreviewMaxHeight(expanded = true))
|
||||
|
||||
// 展开**必须**比收起高:不高就等于「点开等于没点」(主人要的正是「一次多看几行」)——展开态
|
||||
// 若反而变矮,这条会抓住。
|
||||
assertTrue(sharePreviewMaxHeight(expanded = true) > sharePreviewMaxHeight(expanded = false))
|
||||
|
||||
// 但也不许大到把面板顶穿:这个上限管的是**整个预览区**,而它下面还压着描述输入框(最多 4 行)
|
||||
// 与两枚按钮,面板又贴着屏幕底。两倍收起态是这条的边界(240 < 300)。
|
||||
assertTrue(
|
||||
"展开态的上限不该到收起态的两倍",
|
||||
sharePreviewMaxHeight(expanded = true) < sharePreviewMaxHeight(expanded = false) * 2,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the two labels of the control say which way the tap will go`() {
|
||||
// 控件就在这两句之间切换:两句必须**不同**(否则用户看不出此刻是哪一种状态、也猜不到点下去
|
||||
// 会怎样),也都不能是空的(空标签 = 一个看不出是什么的可点区域)。这两句是主人措辞的一部分,
|
||||
// 钉成字面量。
|
||||
assertEquals("展开", SHARE_PREVIEW_EXPAND_LABEL)
|
||||
assertEquals("收起", SHARE_PREVIEW_COLLAPSE_LABEL)
|
||||
assertNotEquals(SHARE_PREVIEW_EXPAND_LABEL, SHARE_PREVIEW_COLLAPSE_LABEL)
|
||||
assertTrue(SHARE_PREVIEW_EXPAND_LABEL.isNotBlank())
|
||||
assertTrue(SHARE_PREVIEW_COLLAPSE_LABEL.isNotBlank())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user