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,6 +1,7 @@
package top.yeij.cyrene.ui.chat package top.yeij.cyrene.ui.chat
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -249,7 +250,8 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
* 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高, * 极端的一次分享(4 张图 + 5 份文件 + 一段长正文,键盘还弹着)会把这一个面板顶得很高,
* 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。 * 而面板在手机上贴着屏幕底(`BottomCenter`):超出的部分会把下面的输入框与两枚按钮挤出屏幕。
* 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、 * 所以预览区自己带一个高度上限 + 内部滚动:内容少时它按内容高(上限不生效),内容多时它滚动、
* 输入框与按钮原地不动。 * 输入框与按钮原地不动。上限按状态取([sharePreviewMaxHeight]):收起态
* [SHARE_PREVIEW_MAX_HEIGHT]、用户展开后 [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT]。
* *
* ## 正文给三行,但三行之外是「滚」不是「省略号」 * ## 正文给三行,但三行之外是「滚」不是「省略号」
* 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板 * 预览只是「分享进来的是什么」的一眼,而用户要的是按一下发送:把一段几万字的正文整个铺开,这个面板
@@ -260,13 +262,32 @@ const val SHARE_CONFIRM_CAPTION_PLACEHOLDER = "加一句描述…"
* 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断 * 个地方放着这段正文**——输入框是「用户附加的描述」(初值恒为空),不再是正文的可编辑副本。于是截断
* 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗 * 成省略号就等于「按发送之前读不到要发的那段正文」,而用户点这颗「发送」要确认的恰恰是它。所以这一窗
* 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。 * 内部可滚([ShareConfirmBody]):**面板的高度一分不涨,正文滚着读完**。
*
* ## 三行读不下时,再给一次「展开」
* 三行窗 + 内部滚动管的是「读得到」,但一次只看得见三行;主人要的是**也能一次多看几行**
* (W56 拍板:滚动与展开**两个都要**)。所以正文排下来超过三行时(判据是
* [sharePreviewTextOverflowsWindow],正好三行不给)卡片右上一枚「展开」把这一窗放到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],再点「收起」回到三行。没超过三行时这一枚**不出现**——
* 短正文上摆一枚点了没反应的「展开」比没有更糟。
*
* 「读不到尾」这条红线在两种状态下都守:收起态窗内滚,展开态由外层这一层滚(正文那一层不再设上限,
* 见 [ShareConfirmBody] 的「展开态为什么干脆不留这一层上限」)。
*/ */
@Composable @Composable
private fun ShareConfirmPreviews(preview: ShareConfirmPreview) { private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
// 展开是用户对**这一段正文**的选择,键在 `preview.text` 上:换一份分享就回到默认的收起,同一份
// 分享里重组(写描述、列表刷新…)保持用户选过的样子。
//
// ⚠️ 转屏(Activity 重建)会回到收起:跨转屏要活下来就得进 `ChatUiState`——W54C 给「已取走的
// 分享」与描述走的就是那条路,而那是 `ChatScreen` / `ChatViewModel` 的地盘(本批不许碰)。
// `rememberSaveable` 是本仓一次都没用过的新写法,而且弹层窗口有没有带上 saveable registry 没在
// 真机上验过,不在这里试——如实留给主人定。
var textExpanded by remember(preview.text) { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = SHARE_PREVIEW_MAX_HEIGHT) .heightIn(max = sharePreviewMaxHeight(textExpanded))
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
@@ -295,6 +316,9 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) }) DocumentChipRow(chips = preview.documents.map { DocumentChips.of(it.toDraftDocument()) })
} }
if (preview.hasText) { if (preview.hasText) {
// 「超没超过三行」只有排完才知道(折行位置由字号与系统的大字体缩放决定),所以它由正文
// 本体量出来之后回调上来,标签这一行凭它决定要不要给出展开控件。
var bodyOverflows by remember(preview.text) { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@@ -302,19 +326,68 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
.background(MiuixTheme.colorScheme.surfaceContainer) .background(MiuixTheme.colorScheme.surfaceContainer)
.padding(10.dp), .padding(10.dp),
) { ) {
Text( Row(
text = "分享的文字", modifier = Modifier.fillMaxWidth(),
style = MiuixTheme.textStyles.footnote2, verticalAlignment = Alignment.CenterVertically,
color = MiuixTheme.colorScheme.onSurfaceVariantSummary, ) {
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 常量 * ## 为什么量行高,而不是写一个 dp 常量
* 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」 * 这个上限必须**正好三行**:矮了会在字身上切一刀(滚动停在一个被切开的字形上),高了就等于把「三行」
@@ -324,27 +397,52 @@ private fun ShareConfirmPreviews(preview: ShareConfirmPreview) {
* 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。 * 所以这里改为**量**:先按没有上限排一次,从 `TextLayoutResult` 上取第三行的底边当上限。
* *
* 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度 * 上限只写一次(`cap` 还是 [Dp.Infinity] 时才改),所以不会来回跳:拿到上限后重新排版,正文的宽度
* 与样式都没变,行位置与首帧一致,量出来的值也一样。 * 与样式都没变,行位置与首帧一致,量出来的值也一样。展开 / 收起切换**也不会改写它**——`cap` 记的
* 恒是收起态那一窗的高度(这一窗的折行只由宽度决定,高度不受限,两种状态下排出来的行位置一样)。
* *
* ⚠️ 代价是**首帧先按完整正文排一次**、下一帧才收到三行:正文很长时,这一窗理论上会有一帧高出一截、 * ## 展开态为什么干脆不留这一层上限
* 然后回落。另一条路——写死一个 dp 上限——没有这一帧,但在非默认字号档上必然切字,两害相权取了 * 展开之后正文可能几屏长。若在这一窗上再设一个上限,就成了「窗外滚 + 窗内滚」两层都要滚的手势
* 「量」。这一帧在实际渲染里看不看得见(弹层这时正在进场,可能被动画盖住)**没有在真机上验过**, * (手指先被内层吃掉、内层滚到底才轮到外层),而两层的上界还互相牵制——最容易出的错就是「明明展开
* 留给主人真机验收。 * 了、还剩半截读不到」。所以展开态**只留外层那一层**:预览区的上限抬到
* [SHARE_PREVIEW_EXPANDED_MAX_HEIGHT],整段正文摊在里面,一层滚到底,读不读得到尾只取决于那一层。
* 代价是展开后不能「只滚正文、图片与文件钉着不动」——那要把两层滚动接起来,这个面板不值这个复杂度。
*
* ## 首帧的那次回流:留着,且确认它看不见(W56 复核)
* 上限是**首帧之后**才量到的,所以长正文会先按没有上限排一次,下一帧才收到三行。上一批留了个悬念:
* 真机上会不会看见一次跳动。复核的结论是**看不见**,两条理由:
* 1. 首帧那一下撑不动任何别的东西——正文块是预览区里的**最后一个**元素(图片、文件都在它上面),
* 而预览区自己还有一层独立的上限在管可视高度:下面没有东西被它顶下去,上面那两样的位置一分
* 不动,可视的像素与量完之后**一样**(那些像素由 `heightIn` 的裁剪决定,裁剪高度不变)。
* 2. 加展开态**不会把它变明显**:展开态本来就没有这一层上限(见上),也就没有「量完再回落」这一步;
* 而展开 / 收起是用户主动的一次布局变化,那一帧的尺寸变化是它的**目的**,不是回流。真正多出来的
* 是展开控件本身——它要到第二帧才出现(行数是排完才知道的),但这一枚也顶不动东西:标签行里那
* 两句话用的是同一个 `footnote2`,行高不变。
*
* 另一条路是在组合期用 `rememberTextMeasurer` 把上限量出来,好让控件从首帧就摆上:那要先知道宽度
* (得外包一层 `BoxWithConstraints`),还要自己复现字号、字体缩放与字体回退,等于把排版做两遍——
* 为一个看不见的跳动换掉结构不划算,所以不做。
* *
* ## 两层滚动怎么分工 * ## 两层滚动怎么分工
* 这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块合起来的高度 * 收起态:这一窗自己滚(正文读完),外层 [ShareConfirmPreviews] 也滚(图片 + 文件芯片 + 这一块
* 上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来就走嵌套滚动, * 合起来的高度上限)。手指停在正文上时先滚正文、滚到底再把余量交给外层——`verticalScroll` 本来
* 不用额外接线。 * 就走嵌套滚动,不用额外接线。展开态下这一窗没有可滚的余量(`heightIn` 给了 [Dp.Infinity]),
* 手势整个交给外层。
*/ */
@Composable @Composable
private fun ShareConfirmBody(text: String) { private fun ShareConfirmBody(
text: String,
expanded: Boolean,
onOverflowChange: (Boolean) -> Unit,
) {
val density = LocalDensity.current val density = LocalDensity.current
var cap by remember(text) { mutableStateOf(Dp.Infinity) } var cap by remember(text) { mutableStateOf(Dp.Infinity) }
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.heightIn(max = cap) // 展开态给 [Dp.Infinity] = 这一层没有上限;这正是**首帧那个状态**(`cap` 一开始就是它),
// 所以展开态不多一条代码路径,也不会比首帧更糟。
.heightIn(max = if (expanded) Dp.Infinity else cap)
.verticalScroll(rememberScrollState()), .verticalScroll(rememberScrollState()),
) { ) {
Text( Text(
@@ -356,22 +454,79 @@ private fun ShareConfirmBody(text: String) {
val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1 val lastVisible = layout.lineCount.coerceAtMost(SHARE_PREVIEW_TEXT_LINES) - 1
cap = with(density) { layout.getLineBottom(lastVisible).toDp() } cap = with(density) { layout.getLineBottom(lastVisible).toDp() }
} }
// 行数与上限无关(这一窗没有 `maxLines`、测量高度不受限,排一次行数就定了),所以
// 这一句同时管着「收不起时要不要给出展开控件」与「展开态下要不要留着它」。
onOverflowChange(sharePreviewTextOverflowsWindow(layout.lineCount))
}, },
) )
} }
} }
/** 预览区的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */ /** 预览区在**收起态**的高度上限(见 [ShareConfirmPreviews]:超出的部分靠内部滚动看)。 */
private val SHARE_PREVIEW_MAX_HEIGHT = 150.dp 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])。 * 预览里正文那一窗给几行(W54D 起**不再截断**,超出的部分在窗内滚动,见 [ShareConfirmBody])。
* *
* 它同时是「要不要给出展开控件」的那条线([sharePreviewTextOverflowsWindow]):正好这么多行就没有
* 东西可展开。
*
* `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有 * `internal` 是为了让 `ShareConfirmTest` 能把这个数钉成字面量:面板的滚动行为单测够不着(本模块没有
* Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。 * Compose UI 测试的依赖),能钉住的只有「这一窗是三行」这条判据本身。
*/ */
internal const val SHARE_PREVIEW_TEXT_LINES = 3 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 package top.yeij.cyrene.ui.chat
import androidx.compose.ui.unit.dp
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import top.yeij.cyrene.domain.share.SharedContent import top.yeij.cyrene.domain.share.SharedContent
@@ -13,7 +15,7 @@ import top.yeij.cyrene.domain.share.SharedDocument
* 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。 * 背景:主人 2026-10-06 拍板——分享进来的内容不再「一到就发」,要像其他 IM 一样先给一个确认面板。
* 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**发送时正文 * 面板本身是 Compose 的东西、只能在真机上看(观感由主人验收),但它**该显示什么**、**发送时正文
* 是哪几段拼起来的**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到 * 是哪几段拼起来的**、**「发送」是否可点**这三件事不该只活在 Composable 里:它们决定了「用户看到
* 的是不是他要发的那一份」。本测试钉住四件事: * 的是不是他要发的那一份」。本测试钉住四件事(W56 加的两条判据另见文末那一节):
* *
* 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序); * 1. **内容一样都不少**:图片与文件按分享进来的顺序原样进预览(多张图的顺序就是发送顺序);
* 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说; * 2. **空的也算没有**:空地址画出来是一个坏掉的缩略图,全空白的正文占着一行什么也不说;
@@ -39,6 +41,14 @@ import top.yeij.cyrene.domain.share.SharedDocument
* 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少) * 没有 Compose UI 测试的依赖),所以这里钉的是它的两个前提——**正文完整地进到预览**(一个字都不少)
* 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与 * 与**这一窗仍然是三行**(面板不随正文变高),见 `the whole body reaches the preview …` 与
* `the preview window stays three lines tall`。 * `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 { class ShareConfirmTest {
@@ -247,4 +257,63 @@ class ShareConfirmTest {
// 当成「用户把正文删光了」而变灰)。它单独钉一条字面量,免得将来有人把它当成回归改回去。 // 当成「用户把正文删光了」而变灰)。它单独钉一条字面量,免得将来有人把它当成回归改回去。
assertTrue(shareConfirmCanSend(SharedContent(text = "看这个"), "")) 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())
}
} }