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:
AskaEth committed 2026-10-06 21:43:12 +08:00
1 parent 9ff2de3a1d
commit 14955ef6c0
2 files changed
+245 -21

No files matched your search

@@ -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())
}
}