涉及功能: 视频卡片跑马灯、时区处理、深色模式主题适配
一、问题背景
1.1 视频卡片文本滚动问题
- 现象: 视频消息卡片下方的文件名过长时不滚动
- 期望: 文件名过长时自动滚动显示
1.2 视频时长不显示
- 现象: 视频卡片左上角不显示时长
- 原因: 发送视频时没有获取并保存 duration 字段
1.3 跑马灯滚动闪烁
- 现象: 文本滚动完后突然闪一下从头播放
- 期望: 无缝循环或平滑过渡
1.4 深色模式按钮颜色问题
- 现象: 空会话列表的”新建会话”按钮在深色模式下都变成白色看不清
- 期望: 深色模式使用浅色背景+深色文字
1.5 聊天按天分隔时区问题
- 现象: 使用 UTC 时间导致日期分隔不正确
- 期望: 使用本地时间进行日期判断
二、方案演进与取舍
2.1 跑马灯实现方案对比
方案 A:自定义往返滚动
实现思路:
- 使用 AnimationController + CurvedAnimation
- forward() 滚动到末尾后 reverse() 回滚
- 通过 addStatusListener 监听动画状态实现往返
代码片段:
void _onAnimationStatus(AnimationStatus status) {
if (status == AnimationStatus.completed) {
Future.delayed(Duration(milliseconds: 1500), () {
if (mounted) _controller.reverse();
});
} else if (status == AnimationStatus.dismissed) {
Future.delayed(Duration(seconds: 2), () {
if (mounted) _controller.forward();
});
}
}问题: 往返滚动虽然没有跳变,但使用体验不如传统跑马灯
方案 B:自定义双文本首尾相接
实现思路:
- 渲染两份相同的文本,中间加间隔
- 使用 AnimationController.repeat() 实现无限循环
- 通过 Transform.translate 移动两份文本
代码片段:
return Row(
children: [
Transform.translate(
offset: Offset(-offset, 0),
child: Text(widget.text, ...),
),
SizedBox(width: _gap),
Transform.translate(
offset: Offset(-offset, 0),
child: Text(widget.text, ...),
),
],
);问题: 仍然有视觉闪烁,因为 Row 布局在 ClipRect 内的处理不够完美
方案 C:使用 marquee 包(最终采用)
实现思路:
- 使用成熟的
marqueeFlutter 包 - 包内部已处理好无缝循环逻辑
优点:
- 成熟稳定,无闪烁
- 可配置参数丰富(velocity, blankSpace, pauseAfterRound 等)
- 无需维护复杂的动画逻辑
安装:
flutter pub add marquee最终代码:
import 'package:marquee/marquee.dart' as marquee_pkg;
// 需要固定高度容器,否则会报 RenderBox was not laid out 错误
return SizedBox(
height: _textHeight,
child: marquee_pkg.Marquee(
text: widget.text,
style: widget.style,
velocity: 30.0, // 每秒 30 像素
blankSpace: 50.0, // 两份文本间距
startPadding: 0.0,
pauseAfterRound: Duration(seconds: 2),
),
);关键坑点:
- Marquee 组件必须有固定高度,否则报布局错误
- 需要通过 TextPainter 测量文本高度后用 SizedBox 包裹
2.2 深色模式按钮颜色方案对比
方案 A:硬编码颜色(错误做法)
// 深色模式
backgroundColor: Colors.white,
foregroundColor: Colors.black87,
// 浅色模式
backgroundColor: ChatColors.primary(context),
foregroundColor: Colors.white,问题: 硬编码导致定色混乱,主题色失效
方案 B:使用主题系统颜色(正确做法)
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: isDark
? theme.colorScheme.primaryContainer
: theme.colorScheme.primary,
foregroundColor: isDark
? theme.colorScheme.onPrimaryContainer
: theme.colorScheme.onPrimary,
),
)优点:
- 颜色随主题自动适配
- 保持设计系统一致性
- 便于后续主题定制
2.3 时区处理方案
问题分析
后端返回的时间字符串(如 2024-12-28T10:30:00Z)是 UTC 时间,DateTime.tryParse() 会正确解析为 UTC DateTime,但在比较日期时如果不转换为本地时间,会导致日期分隔不正确。
例如:UTC 时间 12月28日 23:30 在东八区实际是 12月29日 07:30
修复方案
在所有日期比较和显示处添加 .toLocal() 转换:
DateSeparator 组件:
String _formatDate(DateTime date) {
// 确保使用本地时间
final localDate = date.isUtc ? date.toLocal() : date;
final now = DateTime.now();
final today = DateTime(now.year, now.month, now.day);
final dateOnly = DateTime(localDate.year, localDate.month, localDate.day);
if (dateOnly == today) return '今天';
// ...
}chat_page.dart 日期分隔判断:
// 确保使用本地时间进行比较
final currentLocal = message.createdAt.toLocal();
final prevLocal = prevMessage.createdAt.toLocal();
final currentDate = DateTime(currentLocal.year, currentLocal.month, currentLocal.day);
final prevDate = DateTime(prevLocal.year, prevLocal.month, prevLocal.day);
showDateSeparator = currentDate != prevDate;三、文件修改清单
3.1 chat_widgets.dart
- 添加
marquee包导入 - 重写
_MarqueeText组件使用 marquee 包 - 修复
DateSeparator._formatDate()使用本地时间
3.2 chat_page.dart
- 修改日期分隔判断逻辑使用本地时间
3.3 send_page.dart
- 修复深色模式按钮颜色,使用主题系统颜色
- 改用
FilledButton替代ElevatedButton
3.4 pubspec.yaml
- 添加
marquee: ^2.3.0依赖
四、视频时长获取(前次迭代内容)
4.1 添加工具方法
file_utils.dart:
Future<int?> getVideoDurationMs(String filePath) async {
final player = Player();
try {
await player.open(Media(filePath), play: false);
Duration? duration;
await for (final state in player.stream.duration.timeout(
Duration(seconds: 5),
onTimeout: (sink) => sink.close(),
)) {
if (state.inMilliseconds > 0) {
duration = state;
break;
}
}
return duration?.inMilliseconds;
} finally {
await player.dispose();
}
}4.2 修改发送流程
chat_page.dart:
// 发送视频前获取时长
int? durationMs;
if (isVideo) {
durationMs = await getVideoDurationMs(filePath);
}
appState.enqueueSendUpload(
// ...
durationMs: durationMs,
);五、文件夹选择器新建文件夹(前次迭代内容)
5.1 UI 设计
- 在面包屑导航右侧添加圆形 folder-plus 按钮
- 仅在
folderOnly模式下显示
5.2 实现代码
file_picker_dialog.dart:
if (widget.mode == FilePickerMode.folderOnly)
Container(
width: 32,
height: 32,
margin: EdgeInsets.only(left: 8),
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primaryContainer,
),
child: IconButton(
icon: Icon(TablerIcons.folder_plus, size: 18),
onPressed: _showCreateFolderDialog,
tooltip: '新建文件夹',
),
),六、遇到的问题与解决
6.1 Marquee 布局错误
错误信息:
RenderBox was not laid out: RenderClipRect#3e561Null check operator used on a null value原因: Marquee 组件需要固定高度容器
解决: 使用 TextPainter 测量文本高度,用 SizedBox 包裹
_textHeight = textPainter.height;
// ...
return SizedBox(
height: _textHeight,
child: marquee_pkg.Marquee(...),
);6.2 createFolder API 参数错误
错误: Too many positional arguments: 1 expected, but 2 found
解决: 使用正确的 API createFolderAt(folderName, parentPath)
七、经验总结
-
优先使用成熟库: 跑马灯这种常见需求,使用成熟的
marquee包比自己实现更可靠 -
主题色使用规范: 永远使用
theme.colorScheme而非硬编码颜色,保持设计系统一致性 -
时区处理要彻底: 所有涉及日期比较和显示的地方都要考虑时区转换
-
布局约束要明确: 某些组件(如 Marquee)需要明确的尺寸约束才能正常工作
-
Flutter 包的注意事项: 使用第三方包时要仔细阅读文档,了解其布局要求
八、后续优化建议
- 可以考虑在
SendMessage模型的fromJson中统一做.toLocal()转换 - 跑马灯可以添加开始前的延迟,先看到完整文本
- 深色模式的按钮样式可以抽取为通用组件复用