feat(W69): 气泡外侧显示消息时间 —— 主人贴左 / 昔涟贴右的灰色小字(今天 HH:mm、昨天、今年 M月D日 HH:mm、跨年 YYYY年M月D日)
主人:「用户消息气泡左侧和他的消息气泡右侧还一点点留空的空间 能在这两份区域放一个灰色小字
显示消息发送时间吗?跨天和恒久远的消息也要标明简单日期。」——时间放在气泡**外侧那块空白**里,
不进气泡内部,也不进下方那行角标。
一、文案是纯函数 `messageTimeLabel(createdAt, nowMillis, zone)`
四档按**本地日历日**分(不是「相差不到 24 小时」):今天 `14:30` / 昨天 `昨天 09:05` /
今年内更早 `3月7日 09:05`(月日不补零、时分补零)/ 跨年 `2025年12月31日`(**不带时分**:
一年前的消息精确到分没有意义)。用 `java.time` + `Locale.ROOT`,无新依赖、无新表驱动项。
二、两条口径(都写进 KDoc 里)
1. **未来时间走「今天」那一档**(判据写成 `!day.isBefore(today)`):服务端时钟略快 / 本机略慢
时出现的时间戳没有别的语义,只显示 `HH:mm`;不造「明天」这个词,也不摆一个用户从没见过的
假日期。整整偏一天 / 一年也一起吃下。
2. **`createdAt <= 0` ⇒ 一个字都不画**(返回 `null`):0 / 负数只可能来自「这个键根本没传过来」
或解析事故,画成 `1970年1月1日` / `--:--` 都是替服务端编一个它没说过的答案。
本地降级气泡(`proactive-local:`)**有**真时间(`proactiveIngestFor` 缺席时补 `nowMillis`)
⇒ 照常显示;流式气泡与待发气泡本端没有时间戳 ⇒ 走这一档,与加这一块之前逐像素相同。
三、布局:`BubbleWithTime`(新,`MessageBubble.kt`)
时间与气泡**同一行**(`Box` + 底对齐),外侧用 `padding` 把时间那一条的宽度**先扣掉**,
气泡吃剩下的全部宽度。取舍与理由:
- 时间**不参与 weight 分配**,而是先用 `rememberTextMeasurer` 按它将要用的那个
`MiuixTheme.textStyles.footnote2` 量出**精确宽度**(`Text` 内部只是在同一份 style 上 merge
显式参数 ⇒ 量出来的就是排出来的那一串字),所以它既不会溢出屏幕、也不会被压成两行;
- 气泡可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`:长消息照旧用满能用的宽度,
**代价写进 KDoc**——有时间的消息气泡最多窄一条时间条(这是「与气泡同行」的必然结果);
只有「极窄屏 + 超大字号」才会明显变窄,那时选择如实把日期显示完整(截断 `2025年12月31日`
比窄一点的气泡更亏);
- 不用「让时间先占住宽度」那种零测量写法:`Row` 给非 weight 子节点的宽度是**按子节点顺序**
递减的,「气泡在前」的那一侧必然先把宽度吃光、把时间挤成一条竖线;
- 时间在 `clip` / `background` / `combinedClickable` 之外 ⇒ 气泡的样子、选字范围、长按点击区
一个字都没变;气泡自己那一侧离屏幕的边距与加这一块之前逐像素相同;
- 时间挂在这条消息**最后一个有正文的气泡**上(解析出的末段偶尔只有旁白,那一项不画正文框);
`ContentlessNote`(空气泡位置那句如实说明)同样挂,走同一条路。
四、未动的部分:下方那行角标(来源 / 六态 / 分享 / 「⋯」)语义一个字没改,只是**并列的另一块**;
消息内容 / 排序 / 分页 / 去重 / 「跟随底部」的触发键一概没碰;`MessageGroup` 与
`StreamingBubble` / `PendingBubble` 的既有调用一个像素都没变。
验收:`assembleDebug` 通过(APK 47,278,290 B);
`testDebugUnitTest` 全量 127 suites / 1580 tests / 0 failures / 0 errors / 0 skipped
(基线 126 / 1564 → 新增 `MessageTimeLabelTest` 16 条)。
单测覆盖每一档的两侧:同一天 / 正好午夜 / `23:59` 与次日 `00:01`(只差两分钟但跨天)/
昨天跨月 / 昨天跨年(「昨天」优先于跨年档)/ 今年内更早(月日不补零、一周前仍是这一档)/
跨年(前天、以及整整一年前同一个 `8月14日`)/ 闰年 `2/29`(当年是昨天、次年整日)/
未来(同天 + 几分钟、次日、次年)/ `0` / `-1` / `Long.MIN_VALUE` / 同一毫秒在上海与 UTC 分属两档。
`D` 认成「一年里的第几天」把 `8月14日` 排成 `8月226日`——这条正是被这批单测当场拦下的,已写进
`MessageDayFormat` 的 KDoc。
This commit is contained in:
1 parent
11602c0138
commit
7b39994715
2 files changed
+396
-12
No files matched your search
@@ -25,6 +25,7 @@ import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
@@ -33,6 +34,7 @@ import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.rememberTextMeasurer
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
@@ -61,6 +63,10 @@ import top.yukonga.miuix.kmp.icon.MiuixIcons
|
||||
import top.yukonga.miuix.kmp.icon.basic.Close
|
||||
import top.yukonga.miuix.kmp.icon.extended.More
|
||||
import top.yukonga.miuix.kmp.theme.MiuixTheme
|
||||
import java.time.Instant
|
||||
import java.time.ZoneId
|
||||
import java.time.format.DateTimeFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/** 流式进行中的光标。 */
|
||||
private const val CURSOR = "▍"
|
||||
@@ -74,6 +80,11 @@ private val MESSAGE_ACTION_ICON_SIZE = 18.dp
|
||||
/** 「⋯」与来源角标之间的间距。 */
|
||||
private val MESSAGE_ACTION_GAP = 4.dp
|
||||
|
||||
/**
|
||||
* 气泡外侧那枚时间与气泡之间的空隙:时间贴气泡,但不贴成一体(也不必去够屏幕边)。
|
||||
*/
|
||||
private val MESSAGE_TIME_GAP = 6.dp
|
||||
|
||||
/** 「⋯」的读屏名字,同时也是设备验收时 `uiautomator` 拿来定位它的 `content-desc`。 */
|
||||
private const val MESSAGE_ACTION_DESCRIPTION = "消息操作"
|
||||
|
||||
@@ -129,6 +140,16 @@ fun isContentlessUserMessage(message: HistoryMessageDto): Boolean =
|
||||
* - `user` 消息**什么都没有**([isContentlessUserMessage])时,气泡位置改为一句如实的说明
|
||||
* ([ContentlessNote])——空气泡是最糟的一种呈现:用户完全不知道发生了什么。
|
||||
*
|
||||
* ## 外侧那枚时间(W69)
|
||||
* 每条**落库**消息在气泡外侧再画一枚灰色小字(主人:用户的气泡靠右 ⇒ 时间贴气泡左边;她的气泡
|
||||
* 靠左 ⇒ 时间贴气泡右边):文案由纯函数 [messageTimeLabel] 给(今天 `14:30` / `昨天 09:05` /
|
||||
* `3月7日 09:05` / 跨年 `2025年12月31日`),布局与宽度取舍见 [BubbleWithTime]。
|
||||
* 它是与气泡下方那行角标(来源 / 六态 / 「⋯」)**并列的另一块**:那块说的是「这条从哪来、能对它
|
||||
* 做什么」,这一块说的是「它什么时候发生的」,两处一个字都不改。
|
||||
*
|
||||
* 只有落库消息有它:流式气泡([StreamingBubble])与待发气泡([PendingBubble],[PendingMessage]
|
||||
* 本端就没有时间戳这个概念)走的都是「不显示」那一档。
|
||||
*
|
||||
* 文本可自由选择:每个气泡/旁白各自包一层 [SelectionContainer],
|
||||
* 因此选择菜单的「全选」只作用于当前那一块,而不是整个页面。
|
||||
*
|
||||
@@ -195,6 +216,18 @@ fun MessageBubble(
|
||||
// 所以 ③ 排队中**不会**被渲染成灰态——规范 §1.3 特意点名不要合并这两态。
|
||||
val gray = phase == TurnPhase.Answering
|
||||
|
||||
// 气泡**外侧**那枚时间(W69)。它在组合期按当下时刻算一次,**不 remember**:文案是
|
||||
// (时间戳, 现在, 时区) 的函数,而「现在」只在重组时才有机会更新——冻结它就等于让跨天之后
|
||||
// 还挂着昨天的口径。代价是「页面开着不动、跨度又恰好跨过零点」时它不会自己变,
|
||||
// 不为这一件事加一个 ticker(见 [messageTimeLabel])。
|
||||
val outsideTime = messageTimeLabel(
|
||||
createdAt = message.createdAt,
|
||||
nowMillis = System.currentTimeMillis(),
|
||||
)
|
||||
// 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白
|
||||
// (`<action>`/`<narration>`),那一项不画正文框,挂上去时间就没有落脚点。
|
||||
val timeCarrier = bubbles.indexOfLast { it.body.isNotEmpty() }
|
||||
|
||||
MessageGroup(
|
||||
isUser = isUser,
|
||||
footer = bubbleFooter(
|
||||
@@ -228,7 +261,9 @@ fun MessageBubble(
|
||||
}
|
||||
if (isContentlessUserMessage(message)) {
|
||||
// 空气泡是最糟的一种呈现:用户完全不知道发生了什么。这里如实说一句(见 [ContentlessNote])。
|
||||
ContentlessNote(onLongPress = openMenu)
|
||||
BubbleWithTime(time = outsideTime, isUser = isUser) { side ->
|
||||
ContentlessNote(onLongPress = openMenu, modifier = side)
|
||||
}
|
||||
} else {
|
||||
bubbles.forEachIndexed { index, bubble ->
|
||||
Bubble(
|
||||
@@ -238,6 +273,9 @@ fun MessageBubble(
|
||||
showCursor = gray,
|
||||
onLongPress = openMenu,
|
||||
gray = gray,
|
||||
// 时间挂在**最后一个有正文的气泡**外侧(`-1` = 这条一个都没有,不可能发生,
|
||||
// 但真发生也只是不画时间,不会把时间画到空气泡旁边去)。
|
||||
sideTime = if (index == timeCarrier) outsideTime else null,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -340,6 +378,76 @@ fun bubbleFooter(
|
||||
return parts.takeIf { it.isNotEmpty() }?.joinToString(" · ")
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息**外侧**那枚灰色时间的文案(纯函数,可单测);`null` = 这条一个字都不画。
|
||||
*
|
||||
* 四档,全部按**本地日历日**分(不是「相差不到 24 小时」那种算法:`23:59` 与次日 `00:01` 只差
|
||||
* 两分钟,却已经跨天,这一档必须是「昨天」):
|
||||
* - **今天**(与 `nowMillis` 落在同一个本地日期)→ `14:30`;
|
||||
* - **昨天** → `昨天 09:05`;
|
||||
* - **今年内更早** → `3月7日 09:05`(月日不补零,时分补零);
|
||||
* - **跨年** → `2025年12月31日`。**刻意不带时分**:一年前的消息精确到分没有意义,年月日已经
|
||||
* 把「多久远」说清了,多四个字符反而更难扫。
|
||||
*
|
||||
* ## 未来时间:按「今天」那一档显示(不造「明天」这个词)
|
||||
* 服务端时钟略快 / 本机时钟略慢时会出现几秒到几分钟「之后」的时间戳,它没有任何要表达的语义
|
||||
* (这条就是刚刚到的),所以只显示 `HH:mm`——那正是这条消息发生的那一刻。判据因此写成
|
||||
* `!day.isBefore(today)` 而不是 `day == today`:顺手也把「服务端时钟整整偏了一天 / 一年」这种
|
||||
* 坏时钟吃掉——宁可只显示一个时刻,也不在用户眼皮底下编出一个他从没见过的「明天」或假日期。
|
||||
*
|
||||
* ## 非法 / 缺失(`<= 0`):整条不画
|
||||
* 这个字段是**毫秒 Unix 时间戳**;`0` / 负数只可能来自「这个键根本没传过来」或解析事故,不是
|
||||
* 一个真实时刻。画成 `1970年1月1日` 或 `--:--` 都是在替服务端编一个它没说过的答案;而一条没有
|
||||
* 时间的消息并不缺什么(正文、来源角标、动作入口都还在,见 [MessageBubble])。
|
||||
*
|
||||
* ## 为什么在 UI 层算,而不是让 VM 把文案算好塞进 item
|
||||
* 它只依赖(这条的时间戳,现在,时区)——前两个 UI 层本地就有。为了一个「现在」去动 item 模型与
|
||||
* 列表的 key 不划算,而且那样还会把文案**冻住**:同一秒里算出来的字符串被 diff 当成同一个 item,
|
||||
* 跨天之后那一屏就永远停在旧口径。
|
||||
*
|
||||
* `zone` 可注入(默认本机时区),`nowMillis` 由调用方喂:这条映射自己不读表,所以可单测、可复现
|
||||
* (与 [searchHitTime] 同一条写法)。
|
||||
*/
|
||||
fun messageTimeLabel(
|
||||
createdAt: Long,
|
||||
nowMillis: Long,
|
||||
zone: ZoneId = ZoneId.systemDefault(),
|
||||
): String? {
|
||||
if (createdAt <= 0L) return null
|
||||
val at = Instant.ofEpochMilli(createdAt).atZone(zone)
|
||||
val day = at.toLocalDate()
|
||||
val today = Instant.ofEpochMilli(nowMillis).atZone(zone).toLocalDate()
|
||||
return when {
|
||||
// 今天**与未来**同一档(见 KDoc:未来只可能是时钟漂移,不造「明天」这个词)。
|
||||
!day.isBefore(today) -> at.format(MessageClockFormat)
|
||||
day == today.minusDays(1) -> "$YESTERDAY_PREFIX ${at.format(MessageClockFormat)}"
|
||||
day.year == today.year -> at.format(MessageDayFormat)
|
||||
else -> at.format(MessageDateOnlyFormat)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 「今天」那一档,同时也是其余各档拼时分的零件:24 小时制、补零。
|
||||
*
|
||||
* `Locale.ROOT`:刻意不跟随设备 `locale`——这几档文案的形状是固定的(「3月7日」不会因为系统语言
|
||||
* 变成别的写法),不给本地化留口子。
|
||||
*/
|
||||
private val MessageClockFormat = DateTimeFormatter.ofPattern("HH:mm", Locale.ROOT)
|
||||
|
||||
/**
|
||||
* 「今年内更早」那一档:`3月7日 09:05`(月日**不**补零,与 [messageTimeLabel] 的例一致)。
|
||||
*
|
||||
* 两处模式字母都要盯住:**`d`** 才是「月里的第几天」,大写 **`D`** 是「一年里的第几天」
|
||||
* (`M'月'D'日'` 会把 8 月 14 日排成「8月226日」——W69 的单测就是在这一步拦下的)。
|
||||
*/
|
||||
private val MessageDayFormat = DateTimeFormatter.ofPattern("M'月'd'日' HH:mm", Locale.ROOT)
|
||||
|
||||
/** 「跨年」那一档:`2025年12月31日`(刻意不带时分)。 */
|
||||
private val MessageDateOnlyFormat = DateTimeFormatter.ofPattern("yyyy'年'M'月'd'日'", Locale.ROOT)
|
||||
|
||||
/** 「昨天」那两个字:只与时分连用(`昨天 09:05`),不会单独成句。 */
|
||||
private const val YESTERDAY_PREFIX = "昨天"
|
||||
|
||||
/**
|
||||
* 乐观发送中的用户消息(还没落库)。
|
||||
*
|
||||
@@ -581,6 +689,87 @@ private fun MessageGroup(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 气泡本体 + 它**外侧**那一枚灰色时间(主人 W69):**用户**的气泡靠右 ⇒ 时间贴气泡**左边**;
|
||||
* **她**的气泡靠左 ⇒ 时间贴气泡**右边**。
|
||||
*
|
||||
* ## 时间那一条的宽度是「量」出来的,不参与宽度分配
|
||||
* 气泡可用宽度有限([BubbleBody] 自己封了 `300.dp` 的上限),而时间与气泡**同一行**,必然分掉
|
||||
* 一点横向空间。这里**不让两者去抢 weight**,而是先用 [rememberTextMeasurer] 把这一行字按
|
||||
* **它将要用的那个 [MiuixTheme.textStyles.footnote2]** 量一遍,拿到它排出来的**精确宽度**,
|
||||
* 再用 `padding` 把这条宽度从气泡的可用宽度里**先扣掉**:
|
||||
* - 时间量得准(同一份 `TextStyle`、同一份字体缩放与字体回退,量出来的就是排出来的那一串字——
|
||||
* `Text` 内部只是在它自己那份 `style` 上 merge 显式参数,不参数字段一律不动),所以它**既不会
|
||||
* 溢出屏幕、也不会被压成两行**;
|
||||
* - 气泡吃**剩下的全部**宽度(`padding` 是在约束里扣的,气泡自己的 `widthIn(max = 300.dp)` 照旧
|
||||
* 生效 ⇒ 实际可用宽度 = `min(300.dp, 屏宽 - 两边 14dp - 时间条)`),所以长消息照旧用满能用的宽度。
|
||||
* 代价写在明处:**有时间的消息,气泡比加这一块之前最多窄一条时间的宽度**(这是「时间与气泡同行」
|
||||
* 的必然结果,也是主人要的那件事)。只有「极窄屏 + 超大字号」这一条极端组合下气泡才会明显变窄
|
||||
* ——那时我们选择**如实把日期显示完整**:截断「2025年12月31日」比窄一点的气泡更亏。
|
||||
*
|
||||
* 为什么不用「把时间排在子节点前面、让它先占住宽度」这种零测量的写法:`Row` 给非 weight 子节点的
|
||||
* 宽度是**按子节点顺序**递减的,所以「气泡在前」的那一侧(她那一侧)必然先把宽度吃光、把时间挤成
|
||||
* 一条竖线;而 Compose 的 `Row` 只有「顺序摆放」一种排布,做不到「先量、后摆」。自写 `Layout` 当然
|
||||
* 可以,但要自己接管窄屏下的钳制,而且那份宽度就是同一笔账——量一次是这里最小的一步。
|
||||
*
|
||||
* ## 摆在哪(三条都是「不许动气泡」的具体落实)
|
||||
* - 时间在**气泡外边**:`padding` 在 `clip` / `background` 之外,所以它不是气泡的一部分——底色、
|
||||
* 圆角、[SelectionContainer] 的选字范围、长按的点击区全都与加它之前逐像素 / 逐行为相同;
|
||||
* - 时间与气泡**底对齐**:贴着气泡最后一行(气泡很高时它也不会跑到中间去);
|
||||
* - 与气泡之间有 [MESSAGE_TIME_GAP] 的空隙,免得那串灰字和气泡贴成一体。
|
||||
*
|
||||
* 整块(时间 + 气泡)仍由 [MessageGroup] 那一列按角色对齐(主人靠右、她靠左),所以**气泡自己那
|
||||
* 一侧**离屏幕的边距与没有时间时**逐像素相同**——时间填的正是原本空着的那条区域。
|
||||
*
|
||||
* @param time `null` = 这条消息不显示时间(时间戳缺失 / 非法,见 [messageTimeLabel]):此时**连
|
||||
* `Box` 都不建**,整个气泡与加这一块之前逐像素相同——流式气泡与待发气泡走的正是这一档
|
||||
* (后者本端还没有任何时间戳)。
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleWithTime(
|
||||
time: String?,
|
||||
isUser: Boolean,
|
||||
bubble: @Composable (Modifier) -> Unit,
|
||||
) {
|
||||
if (time == null) {
|
||||
bubble(Modifier)
|
||||
return
|
||||
}
|
||||
val measurer = rememberTextMeasurer()
|
||||
val style = MiuixTheme.textStyles.footnote2
|
||||
val density = LocalDensity.current
|
||||
val strip = remember(measurer, density, time, style) {
|
||||
with(density) {
|
||||
measurer.measure(text = time, style = style, maxLines = 1, softWrap = false).size.width.toDp()
|
||||
} + MESSAGE_TIME_GAP
|
||||
}
|
||||
Box {
|
||||
bubble(
|
||||
Modifier
|
||||
.then(if (isUser) Modifier.padding(start = strip) else Modifier.padding(end = strip))
|
||||
// 气泡贴回它自己那一侧:`strip` 已经吃掉了可用的余量,这里的对齐在正常宽度下是
|
||||
// 恒等的(气泡就是最宽的那个孩子),留着是为了「万一条比气泡还宽」时也仍在原位。
|
||||
.align(if (isUser) Alignment.BottomEnd else Alignment.BottomStart),
|
||||
)
|
||||
Text(
|
||||
text = time,
|
||||
style = style,
|
||||
color = MiuixTheme.colorScheme.onSurfaceVariantSummary,
|
||||
// 宽度已经量过、条也留够了,正常情况下一行必放得下;这两个参数是防「量完到排完之间
|
||||
// 密度变了」这类不该发生的事——真发生也只是这串字自己被截,不会去挤气泡。
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
modifier = Modifier.align(if (isUser) Alignment.BottomStart else Alignment.BottomEnd),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 一条消息在气泡层渲染成一个 / 多个气泡。
|
||||
*
|
||||
* [sideTime] 只由**携带时间的那个气泡**(最后一个有正文的,见 [MessageBubble])收到,
|
||||
* 其余气泡与旁白行都不带——时间挂在这条消息上一次,不跟着每个气泡重复。
|
||||
*/
|
||||
@Composable
|
||||
private fun Bubble(
|
||||
bubble: ParsedBubble,
|
||||
@@ -589,19 +778,23 @@ private fun Bubble(
|
||||
showCursor: Boolean = false,
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
sideTime: String? = null,
|
||||
) {
|
||||
AnnotationLine(bubble.head)
|
||||
val withCursor = showCursor && isLast && bubble.body.isNotEmpty()
|
||||
val body = if (withCursor) bubble.body + CURSOR else bubble.body
|
||||
if (body.isNotEmpty()) {
|
||||
BubbleBody(
|
||||
text = body,
|
||||
runs = bubble.runs,
|
||||
isUser = isUser,
|
||||
onLongPress = onLongPress,
|
||||
gray = gray,
|
||||
trailingCursor = withCursor,
|
||||
)
|
||||
BubbleWithTime(time = sideTime, isUser = isUser) { side ->
|
||||
BubbleBody(
|
||||
text = body,
|
||||
runs = bubble.runs,
|
||||
isUser = isUser,
|
||||
onLongPress = onLongPress,
|
||||
gray = gray,
|
||||
trailingCursor = withCursor,
|
||||
modifier = side,
|
||||
)
|
||||
}
|
||||
}
|
||||
AnnotationLine(bubble.tail)
|
||||
}
|
||||
@@ -667,11 +860,14 @@ private fun AnnotationLine(parts: List<ContentPart>) {
|
||||
* ## 长按仍然开动作菜单
|
||||
* 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。
|
||||
* 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [MessageBubble] 的那一个入口。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`:
|
||||
* 和 [BubbleBody] 一样,它是**气泡外边**的留白,不参与这张灰底方块的绘制与点击区。
|
||||
*/
|
||||
@Composable
|
||||
private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
|
||||
private fun ContentlessNote(onLongPress: (() -> Unit)? = null, modifier: Modifier = Modifier) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 300.dp)
|
||||
.clip(BubbleShape)
|
||||
.background(MiuixTheme.colorScheme.surfaceContainer)
|
||||
@@ -712,6 +908,10 @@ private fun ContentlessNote(onLongPress: (() -> Unit)? = null) {
|
||||
*
|
||||
* [trailingCursor] 说的是「`text` 末尾那枚光标」。光标必须**在片段之外**补上去:它不是正文,
|
||||
* 不该继承她最后那一段的粗体/等宽底色。
|
||||
*
|
||||
* [modifier] 由调用方([BubbleWithTime])喂进来,装着「外侧那枚时间占掉的那一条」的 `padding`。
|
||||
* 它排在这条链的**最前面**,所以那一层留白在 `clip` / `background` / `combinedClickable` 之外:
|
||||
* 气泡自己的样子、选字范围、长按的点击区都还是原来的那一块。
|
||||
*/
|
||||
@Composable
|
||||
private fun BubbleBody(
|
||||
@@ -721,6 +921,7 @@ private fun BubbleBody(
|
||||
onLongPress: (() -> Unit)? = null,
|
||||
gray: Boolean = false,
|
||||
trailingCursor: Boolean = false,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val contentColor = when {
|
||||
gray -> MiuixTheme.colorScheme.onSurfaceVariantSummary
|
||||
@@ -728,7 +929,7 @@ private fun BubbleBody(
|
||||
else -> MiuixTheme.colorScheme.onSurfaceContainer
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
modifier = modifier
|
||||
.widthIn(max = 300.dp)
|
||||
// clip + background 与原来那句 `background(color, shape)` 画出来完全一样,
|
||||
// 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
package top.yeij.cyrene.ui.chat
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
import java.time.ZoneId
|
||||
import java.time.ZonedDateTime
|
||||
|
||||
/**
|
||||
* 气泡外侧那枚时间([messageTimeLabel])的回归钉子。
|
||||
*
|
||||
* 四档文案错了不会有异常、也不会有编译错误——只是屏幕上写着一个错的日子,而且最容易错的恰恰是
|
||||
* 边界(`23:59` 与次日 `00:01`、12 月 31 日与 1 月 1 日、闰年 2 月 29 日)。所以这里把每一档的
|
||||
* 两侧都钉住:判据按**本地日历日**分,不是「相差不到 24 小时」。
|
||||
*
|
||||
* 时区与「现在」都由测试喂(`zone` 注入、`nowMillis` 传参),所以这些断言与跑测试的机器无关。
|
||||
*/
|
||||
class MessageTimeLabelTest {
|
||||
|
||||
private val shanghai = ZoneId.of("Asia/Shanghai")
|
||||
private val utc = ZoneId.of("UTC")
|
||||
|
||||
/** 本地 `yyyy-MM-dd HH:mm` → 毫秒:测试里只写看得懂的时刻,不摆魔数。 */
|
||||
private fun at(
|
||||
year: Int,
|
||||
month: Int,
|
||||
day: Int,
|
||||
hour: Int,
|
||||
minute: Int,
|
||||
zone: ZoneId = shanghai,
|
||||
): Long = ZonedDateTime.of(year, month, day, hour, minute, 0, 0, zone).toInstant().toEpochMilli()
|
||||
|
||||
// ---------------------------------------------------------------- 今天:只给时分
|
||||
|
||||
@Test
|
||||
fun `same day renders hour and minute`() {
|
||||
val now = at(2025, 8, 14, 20, 0)
|
||||
|
||||
assertEquals("14:30", messageTimeLabel(at(2025, 8, 14, 14, 30), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `exactly midnight is still today`() {
|
||||
val now = at(2025, 8, 14, 9, 0)
|
||||
|
||||
assertEquals("00:00", messageTimeLabel(at(2025, 8, 14, 0, 0), now, shanghai))
|
||||
assertEquals("23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), at(2025, 8, 14, 23, 59), shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `one digit hours and minutes stay zero padded`() {
|
||||
val now = at(2025, 8, 14, 12, 0)
|
||||
|
||||
assertEquals("09:05", messageTimeLabel(at(2025, 8, 14, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 昨天:跨天不由时长决定
|
||||
|
||||
@Test
|
||||
fun `two minutes across midnight flips to yesterday`() {
|
||||
val now = at(2025, 8, 15, 0, 1)
|
||||
|
||||
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 8, 14, 23, 59), now, shanghai))
|
||||
assertEquals("00:01", messageTimeLabel(at(2025, 8, 15, 0, 1), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday keeps the time`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
assertEquals("昨天 09:05", messageTimeLabel(at(2025, 8, 13, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday across a month boundary`() {
|
||||
val now = at(2025, 3, 1, 10, 0)
|
||||
|
||||
// 2025-02-28 → 2025-03-01 是**昨天**(不是「2月28日」那档),月份少一天 / 多一天都不影响。
|
||||
assertEquals("昨天 23:30", messageTimeLabel(at(2025, 2, 28, 23, 30), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `yesterday across a year boundary`() {
|
||||
val now = at(2026, 1, 1, 0, 1)
|
||||
|
||||
// 12-31 与 01-01 只差两分钟,「昨天」这一档**优先于**跨年那一档。
|
||||
assertEquals("昨天 23:59", messageTimeLabel(at(2025, 12, 31, 23, 59), now, shanghai))
|
||||
assertEquals("昨天 09:00", messageTimeLabel(at(2025, 12, 31, 9, 0), at(2026, 1, 1, 0, 30), shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 今年内更早:月日 + 时分
|
||||
|
||||
@Test
|
||||
fun `earlier this year shows month day and time`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
assertEquals("3月7日 09:05", messageTimeLabel(at(2025, 3, 7, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `month and day are not zero padded`() {
|
||||
val now = at(2025, 12, 31, 23, 59)
|
||||
|
||||
assertEquals("1月1日 00:00", messageTimeLabel(at(2025, 1, 1, 0, 0), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a week ago stays in the month day branch`() {
|
||||
val now = at(2025, 8, 14, 10, 0)
|
||||
|
||||
// 没有「星期几」这一档:七天前与半年后走的是同一档。
|
||||
assertEquals("8月7日 09:05", messageTimeLabel(at(2025, 8, 7, 9, 5), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 跨年:只给年月日
|
||||
|
||||
@Test
|
||||
fun `crossing the year boundary drops the clock`() {
|
||||
val now = at(2026, 1, 1, 0, 1)
|
||||
|
||||
assertEquals("2025年12月30日", messageTimeLabel(at(2025, 12, 30, 23, 59), now, shanghai))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the year decides the branch not the elapsed time`() {
|
||||
val now = at(2026, 8, 14, 10, 0)
|
||||
|
||||
// 整整一年前、同一个「8月14日」:仍然是跨年那一档(不带时分)。
|
||||
assertEquals("2025年8月14日", messageTimeLabel(at(2025, 8, 14, 10, 0), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 闰年
|
||||
|
||||
@Test
|
||||
fun `leap day is yesterday in a leap year and a full date afterwards`() {
|
||||
assertEquals(
|
||||
"昨天 23:59",
|
||||
messageTimeLabel(at(2024, 2, 29, 23, 59), at(2024, 3, 1, 0, 0), shanghai),
|
||||
)
|
||||
assertEquals(
|
||||
"2024年2月29日",
|
||||
messageTimeLabel(at(2024, 2, 29, 12, 0), at(2025, 3, 1, 12, 0), shanghai),
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 未来时间(时钟漂移)
|
||||
|
||||
@Test
|
||||
fun `future timestamps fall back to the today branch`() {
|
||||
val now = at(2025, 8, 14, 14, 30)
|
||||
|
||||
// 同一刻前几分钟:服务端时钟略快 / 本机略慢,就是个普通时刻。
|
||||
assertEquals("14:35", messageTimeLabel(at(2025, 8, 14, 14, 35), now, shanghai))
|
||||
// 整整一天 / 一年之后也一样:不造「明天」这个词,也不摆一个假日期(见 KDoc)。
|
||||
assertEquals("09:00", messageTimeLabel(at(2025, 8, 15, 9, 0), now, shanghai))
|
||||
assertEquals("00:00", messageTimeLabel(at(2026, 1, 1, 0, 0), now, shanghai))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 缺失 / 非法
|
||||
|
||||
@Test
|
||||
fun `missing or bogus timestamps render nothing`() {
|
||||
val now = at(2025, 8, 14, 14, 30)
|
||||
|
||||
// 0 = 这个键根本没传过来;负数只可能来自解析事故。都不是真实时刻 ⇒ 一个字都不画。
|
||||
assertNull(messageTimeLabel(0L, now, shanghai))
|
||||
assertNull(messageTimeLabel(-1L, now, shanghai))
|
||||
assertNull(messageTimeLabel(Long.MIN_VALUE, now, shanghai))
|
||||
assertNull(messageTimeLabel(-1L, now, utc))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- 时区
|
||||
|
||||
@Test
|
||||
fun `the day boundary follows the injected zone`() {
|
||||
// 同一毫秒:上海是 8 月 15 日 07:30(今天),UTC 还是 8 月 14 日 23:30(昨天)。
|
||||
val millis = at(2025, 8, 14, 23, 30, utc)
|
||||
val now = at(2025, 8, 15, 1, 0, utc)
|
||||
|
||||
assertEquals("07:30", messageTimeLabel(millis, now, shanghai))
|
||||
assertEquals("昨天 23:30", messageTimeLabel(millis, now, utc))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user