From 7b39994715b8df73401aff89f0e78fca93e0107a Mon Sep 17 00:00:00 2001 From: AskaEth Date: Wed, 7 Oct 2026 18:57:14 +0800 Subject: [PATCH] =?UTF-8?q?feat(W69):=20=E6=B0=94=E6=B3=A1=E5=A4=96?= =?UTF-8?q?=E4=BE=A7=E6=98=BE=E7=A4=BA=E6=B6=88=E6=81=AF=E6=97=B6=E9=97=B4?= =?UTF-8?q?=20=E2=80=94=E2=80=94=20=E4=B8=BB=E4=BA=BA=E8=B4=B4=E5=B7=A6=20?= =?UTF-8?q?/=20=E6=98=94=E6=B6=9F=E8=B4=B4=E5=8F=B3=E7=9A=84=E7=81=B0?= =?UTF-8?q?=E8=89=B2=E5=B0=8F=E5=AD=97=EF=BC=88=E4=BB=8A=E5=A4=A9=20`HH:mm?= =?UTF-8?q?`=E3=80=81=E6=98=A8=E5=A4=A9=E3=80=81=E4=BB=8A=E5=B9=B4=20`M?= =?UTF-8?q?=E6=9C=88D=E6=97=A5=20HH:mm`=E3=80=81=E8=B7=A8=E5=B9=B4=20`YYYY?= =?UTF-8?q?=E5=B9=B4M=E6=9C=88D=E6=97=A5`=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 主人:「用户消息气泡左侧和他的消息气泡右侧还一点点留空的空间 能在这两份区域放一个灰色小字 显示消息发送时间吗?跨天和恒久远的消息也要标明简单日期。」——时间放在气泡**外侧那块空白**里, 不进气泡内部,也不进下方那行角标。 一、文案是纯函数 `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。 --- .../top/yeij/cyrene/ui/chat/MessageBubble.kt | 225 +++++++++++++++++- .../cyrene/ui/chat/MessageTimeLabelTest.kt | 183 ++++++++++++++ 2 files changed, 396 insertions(+), 12 deletions(-) create mode 100644 app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeLabelTest.kt diff --git a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt index 1c13ca1..e2f1eb4 100644 --- a/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt +++ b/app/src/main/kotlin/top/yeij/cyrene/ui/chat/MessageBubble.kt @@ -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(), + ) + // 时间挂在**最后一个有正文的气泡**上(而不是「列表最后一项」):解析出的末段偶尔只有旁白 + // (``/``),那一项不画正文框,挂上去时间就没有落脚点。 + 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) { * ## 长按仍然开动作菜单 * 这条消息在服务端是真实存在的一行,用户能做的事里最相关的一件就是**删掉它**(内容永远到不了了)。 * 气泡没有正文可以让长按去选字,所以长按在这里没有歧义,直接接 [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)` 画出来完全一样, // 多出来的这一层是为了让长按的涟漪被圆角裁住(否则它会溢出到方角外)。 diff --git a/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeLabelTest.kt b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeLabelTest.kt new file mode 100644 index 0000000..7730924 --- /dev/null +++ b/app/src/test/kotlin/top/yeij/cyrene/ui/chat/MessageTimeLabelTest.kt @@ -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)) + } +}