feat(file-download): 文件下载全量实现 (#14~#18)
- CdnUrlResolver: 相对路径/S3 URL 统一解析为 apiBaseUrl 代理链路 (#14) - FileDownloadManager: Notifier<Map<url, state>>,四态(idle/progress/done/failed), 断点续传 + 取消令牌 + 本地缓存(systemTemp/im_file_cache) (#15) - FileMessageBubble: 三态 UI,文件类型图标/颜色,大小格式化, idle/failed 点击触发下载,done 点击回调 open_filex TODO (#16) - AudioMessageBubble: 语音消息下载框架,静态波形装饰, 播放 TODO(audioplayers 接入后解开) (#17) - VideoMessageBubble: 缩略图 Image.network + CdnUrlResolver, 播放按钮覆盖,上传进度环,video_player 接入 TODO (#18) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
163
Doc/file_download_architecture.md
Normal file
163
Doc/file_download_architecture.md
Normal file
@@ -0,0 +1,163 @@
|
|||||||
|
# 文件下载 — 架构文档
|
||||||
|
|
||||||
|
> 对应 Gitea issues #14–#18
|
||||||
|
> 参考实现:`im-client-ios-swift-demo` FileDownloadManager + AudioPlaybackService + ChatMessageBubble
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 功能范围
|
||||||
|
|
||||||
|
| Issue | 功能 | 状态 |
|
||||||
|
|-------|------|------|
|
||||||
|
| #14 | CDN URL 解析器(CdnUrlResolver) | ✅ 已实现 |
|
||||||
|
| #15 | FileDownloadManager — 下载服务 + 缓存 | ✅ 已实现 |
|
||||||
|
| #16 | FileMessageBubble — 文件气泡三态 UI | ✅ 已实现 |
|
||||||
|
| #17 | AudioMessageBubble — 语音下载框架 | ✅ 框架(播放需 audioplayers) |
|
||||||
|
| #18 | VideoMessageBubble — 视频缩略图框架 | ✅ 框架(播放需 video_player) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
core/services/
|
||||||
|
├── cdn_url_resolver.dart # CDN 路径 → 完整 URL
|
||||||
|
└── file_download_manager.dart # 下载服务(进度追踪 + 缓存)
|
||||||
|
|
||||||
|
features/chat/view/widgets/
|
||||||
|
├── file_message_bubble.dart # 文件消息气泡(三态 UI)
|
||||||
|
├── audio_message_bubble.dart # 语音消息气泡(下载框架)
|
||||||
|
└── video_message_bubble.dart # 视频消息气泡(缩略图框架)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 核心概念
|
||||||
|
|
||||||
|
### 3.1 FileDownloadState
|
||||||
|
|
||||||
|
```
|
||||||
|
idle → 未下载(显示下载图标)
|
||||||
|
downloading(progress: 0.0~1.0) → 下载中(进度条)
|
||||||
|
downloaded(localPath: String) → 已缓存(显示打开图标)
|
||||||
|
failed(error: String) → 失败(显示重试图标)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.2 CDN URL 路由策略
|
||||||
|
|
||||||
|
```
|
||||||
|
相对路径 Files/xxx.pdf → apiBaseUrl/Files/xxx.pdf
|
||||||
|
完整 HTTP http://xxx.com/f → 直接使用
|
||||||
|
S3 URL *.amazonaws.com → apiBaseUrl + S3 path(通过 API 代理)
|
||||||
|
```
|
||||||
|
|
||||||
|
S3 桶是私有的,直接访问返回 403。所有下载都必须经过 `apiBaseUrl` 代理。
|
||||||
|
|
||||||
|
### 3.3 缓存策略
|
||||||
|
|
||||||
|
```
|
||||||
|
缓存目录:Directory.systemTemp/im_file_cache/
|
||||||
|
文件名: {url.hashCode 8位 hex}_{sanitized_filename}
|
||||||
|
命中条件:文件存在 && 大小 > 0
|
||||||
|
清理策略:由 OS 自动清理 temp 目录(同 iOS Library/Caches 语义)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 数据流
|
||||||
|
|
||||||
|
### 4.1 文件消息下载
|
||||||
|
|
||||||
|
```
|
||||||
|
FileMessageBubble.onTap
|
||||||
|
└─ fileDownloadManagerProvider.notifier.download(url, fileName)
|
||||||
|
├─ CdnUrlResolver.resolve(url) → 完整下载 URL
|
||||||
|
├─ _cacheKeyFor(url, fileName) → 本地文件名
|
||||||
|
├─ File(cachePath).exists() ?
|
||||||
|
│ true → state = downloaded(cachePath)
|
||||||
|
│ false → NetworksSdkApi.executeDownload(url, savePath, onProgress)
|
||||||
|
│ → state = downloading(progress)
|
||||||
|
│ → state = downloaded(cachePath) / failed(error)
|
||||||
|
└─ 返回 localPath(供 open_filex 打开)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.2 语音消息
|
||||||
|
|
||||||
|
```
|
||||||
|
AudioMessageBubble
|
||||||
|
└─ 点击播放按钮
|
||||||
|
└─ FileDownloadManager.download(voiceUrl, 'voice_{cmid}.m4a')
|
||||||
|
→ downloaded(localPath)
|
||||||
|
→ TODO: AudioPlayer().play(DeviceFileSource(localPath))
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4.3 视频消息
|
||||||
|
|
||||||
|
```
|
||||||
|
VideoMessageBubble
|
||||||
|
└─ 显示:Image.network(CdnUrlResolver.resolve(thumbUrl)) // 缩略图
|
||||||
|
└─ 点击播放按钮
|
||||||
|
→ TODO: Navigator 推送全屏视频页(video_player 流式播放)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Provider 设计
|
||||||
|
|
||||||
|
```
|
||||||
|
fileDownloadManagerProvider
|
||||||
|
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>
|
||||||
|
└─ NetworksSdkApi (via networkSdkApiProvider)
|
||||||
|
```
|
||||||
|
|
||||||
|
与 Settings ViewModel 一致,使用手动 `Notifier<T>` + `NotifierProvider`,
|
||||||
|
不依赖 `@riverpod` 代码生成。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 消息类型路由(rawContent 解析)
|
||||||
|
|
||||||
|
| typ | 类型 | rawContent 格式 |
|
||||||
|
|-----|------|----------------|
|
||||||
|
| 3 | 语音 | `{"url":"...","duration":N}` |
|
||||||
|
| 4 | 视频 | `{"url":"...","thumb":"...","size":N}` |
|
||||||
|
| 6 | 文件 | `{"url":"...","name":"...","size":N}` |
|
||||||
|
| 24 | 视频(秘密) | 同 typ=4 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 待完成
|
||||||
|
|
||||||
|
- **audioplayers 接入**:`AudioMessageBubble` 中 TODO 解开,安装 `audioplayers: ^6.0.0`
|
||||||
|
- **video_player 接入**:`VideoMessageBubble` 点击推送全屏路由,安装 `video_player: ^2.9.0`
|
||||||
|
- **open_filex 接入**:`FileMessageBubble` downloaded 态打开文件,安装 `open_filex: ^3.0.3`
|
||||||
|
- **XOR 解密**:`secret/` 路径文件解密(cipher_guard_sdk 接入时实现)
|
||||||
|
|
||||||
|
### 接入 open_filex 步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# apps/im_app/pubspec.yaml
|
||||||
|
dependencies:
|
||||||
|
open_filex: ^3.0.3
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// FileMessageBubble downloaded 态 onTap:
|
||||||
|
import 'package:open_filex/open_filex.dart';
|
||||||
|
await OpenFilex.open(localPath);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 接入 audioplayers 步骤
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# apps/im_app/pubspec.yaml
|
||||||
|
dependencies:
|
||||||
|
audioplayers: ^6.0.0
|
||||||
|
```
|
||||||
|
|
||||||
|
```dart
|
||||||
|
// AudioMessageBubble 播放:
|
||||||
|
import 'package:audioplayers/audioplayers.dart';
|
||||||
|
final player = AudioPlayer();
|
||||||
|
await player.play(DeviceFileSource(localPath));
|
||||||
|
```
|
||||||
72
apps/im_app/lib/core/services/cdn_url_resolver.dart
Normal file
72
apps/im_app/lib/core/services/cdn_url_resolver.dart
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
import 'package:im_app/core/foundation/config.dart';
|
||||||
|
|
||||||
|
/// CDN URL 解析器
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #14
|
||||||
|
///
|
||||||
|
/// 文件/图片/语音/视频的 CDN 路径有三种形态:
|
||||||
|
/// 1. 相对路径(如 `Files/xxx.pdf`, `Image/xxx.jpg`)
|
||||||
|
/// → 拼接 [AppConfig.apiBaseUrl]
|
||||||
|
/// 2. 完整 http/https URL(非 S3)
|
||||||
|
/// → 直接返回
|
||||||
|
/// 3. S3 URL(含 `.amazonaws.com`)
|
||||||
|
/// → 路由通过 [AppConfig.apiBaseUrl] 代理(S3 桶是私有的,直接访问 403)
|
||||||
|
/// → 提取 path 部分拼接 baseUrl
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// final url = CdnUrlResolver.resolve('Files/report.pdf');
|
||||||
|
/// // → 'http://gateway.winwayinfo.com/Files/report.pdf'
|
||||||
|
/// ```
|
||||||
|
// ignore: avoid_classes_with_only_static_members
|
||||||
|
class CdnUrlResolver {
|
||||||
|
CdnUrlResolver._();
|
||||||
|
|
||||||
|
/// 将任意格式的 CDN 路径解析为完整可下载 URL
|
||||||
|
static String resolve(String path) {
|
||||||
|
if (path.isEmpty) return path;
|
||||||
|
|
||||||
|
// S3 直链 → 路由通过 API 代理
|
||||||
|
if (_isS3Url(path)) {
|
||||||
|
return _proxyS3(path);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 完整 HTTP URL(非 S3)→ 直接使用
|
||||||
|
if (path.startsWith('http://') || path.startsWith('https://')) {
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相对路径 → 拼接 baseUrl
|
||||||
|
final base = AppConfig.apiBaseUrl.endsWith('/')
|
||||||
|
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
|
||||||
|
: AppConfig.apiBaseUrl;
|
||||||
|
final suffix = path.startsWith('/') ? path : '/$path';
|
||||||
|
return '$base$suffix';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 判断是否为 S3 URL
|
||||||
|
static bool _isS3Url(String url) {
|
||||||
|
return url.contains('.amazonaws.com') ||
|
||||||
|
url.contains('s3-accelerate') ||
|
||||||
|
url.contains('s3.amazonaws');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// S3 URL → 通过 apiBaseUrl 代理
|
||||||
|
///
|
||||||
|
/// S3 presigned URL 带有 `/bucket-name/path?X-Amz-...` 结构,
|
||||||
|
/// 取 path 部分(去掉 query string)拼接 baseUrl。
|
||||||
|
static String _proxyS3(String s3Url) {
|
||||||
|
try {
|
||||||
|
final uri = Uri.parse(s3Url);
|
||||||
|
// path 可能是 /bucket/Image/xxx 或 /Image/xxx,取最后有意义的段
|
||||||
|
final path = uri.path;
|
||||||
|
final base = AppConfig.apiBaseUrl.endsWith('/')
|
||||||
|
? AppConfig.apiBaseUrl.substring(0, AppConfig.apiBaseUrl.length - 1)
|
||||||
|
: AppConfig.apiBaseUrl;
|
||||||
|
return '$base$path';
|
||||||
|
} catch (_) {
|
||||||
|
return s3Url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
183
apps/im_app/lib/core/services/file_download_manager.dart
Normal file
183
apps/im_app/lib/core/services/file_download_manager.dart
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:networks_sdk/networks_sdk.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/app/di/network_provider.dart';
|
||||||
|
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||||
|
|
||||||
|
/// 单文件下载状态
|
||||||
|
///
|
||||||
|
/// 对应 iOS `FileDownloadState` enum
|
||||||
|
sealed class FileDownloadState {
|
||||||
|
const FileDownloadState();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 未下载(初始态)
|
||||||
|
class FileDownloadIdle extends FileDownloadState {
|
||||||
|
const FileDownloadIdle();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载中(0.0 ~ 1.0)
|
||||||
|
class FileDownloadProgress extends FileDownloadState {
|
||||||
|
final double progress;
|
||||||
|
const FileDownloadProgress(this.progress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 已下载,localPath 为本地缓存路径
|
||||||
|
class FileDownloadDone extends FileDownloadState {
|
||||||
|
final String localPath;
|
||||||
|
const FileDownloadDone(this.localPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载失败
|
||||||
|
class FileDownloadFailed extends FileDownloadState {
|
||||||
|
final String error;
|
||||||
|
const FileDownloadFailed(this.error);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件下载管理器
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #15
|
||||||
|
///
|
||||||
|
/// 维护 URL → [FileDownloadState] 的映射,通过 [NetworksSdkApi.executeDownload]
|
||||||
|
/// 执行带进度的文件下载(内部支持断点续传)。
|
||||||
|
///
|
||||||
|
/// ## 缓存策略
|
||||||
|
///
|
||||||
|
/// 缓存目录:`Directory.systemTemp/im_file_cache/`(同 iOS Library/Caches 语义,OS 负责清理)
|
||||||
|
/// 缓存命名:`{url.hashCode 8位 hex}_{sanitized_filename}`
|
||||||
|
///
|
||||||
|
/// ## 幂等性
|
||||||
|
///
|
||||||
|
/// [download] 先检查本地缓存,命中则直接更新状态为 [FileDownloadDone],不发起网络请求。
|
||||||
|
///
|
||||||
|
/// ## 并发控制
|
||||||
|
///
|
||||||
|
/// 同一 URL 下载中时再次调用 [download] 会静默忽略。
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// // 触发下载
|
||||||
|
/// ref.read(fileDownloadManagerProvider.notifier).download(
|
||||||
|
/// url: 'Files/report.pdf',
|
||||||
|
/// fileName: 'report.pdf',
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// // 读取状态
|
||||||
|
/// final dlState = ref.watch(fileDownloadManagerProvider.select(
|
||||||
|
/// (map) => map['Files/report.pdf'],
|
||||||
|
/// ));
|
||||||
|
/// ```
|
||||||
|
class FileDownloadManager extends Notifier<Map<String, FileDownloadState>> {
|
||||||
|
/// 进行中的取消令牌,key = 原始 URL
|
||||||
|
final _cancelTokens = <String, CancelToken>{};
|
||||||
|
|
||||||
|
/// 已主动取消的 URL,防止 catch 块将其状态覆盖为 failed
|
||||||
|
final _cancelledUrls = <String>{};
|
||||||
|
|
||||||
|
@override
|
||||||
|
Map<String, FileDownloadState> build() => {};
|
||||||
|
|
||||||
|
// ── 公开 API ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/// 下载文件(幂等)
|
||||||
|
///
|
||||||
|
/// [url] 原始 CDN 路径(相对路径或完整 URL),由 [CdnUrlResolver.resolve] 处理。
|
||||||
|
/// [fileName] 原始文件名(用于缓存命名和 MIME 类型推断)。
|
||||||
|
///
|
||||||
|
/// 返回本地缓存路径(已缓存时立即返回,新下载完成后返回)。
|
||||||
|
Future<String?> download({
|
||||||
|
required String url,
|
||||||
|
required String fileName,
|
||||||
|
}) async {
|
||||||
|
// 检查本地缓存
|
||||||
|
final cachePath = await _cachePath(url, fileName);
|
||||||
|
final file = File(cachePath);
|
||||||
|
if (file.existsSync() && file.lengthSync() > 0) {
|
||||||
|
_updateState(url, FileDownloadDone(cachePath));
|
||||||
|
return cachePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 已在下载中,忽略
|
||||||
|
final current = state[url];
|
||||||
|
if (current is FileDownloadProgress) return null;
|
||||||
|
|
||||||
|
// 开始下载
|
||||||
|
_updateState(url, const FileDownloadProgress(0));
|
||||||
|
final cancelToken = CancelToken();
|
||||||
|
_cancelTokens[url] = cancelToken;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final resolvedUrl = CdnUrlResolver.resolve(url);
|
||||||
|
final api = ref.read(networkSdkApiProvider);
|
||||||
|
|
||||||
|
await api.executeDownload(
|
||||||
|
url: resolvedUrl,
|
||||||
|
savePath: cachePath,
|
||||||
|
cancelToken: cancelToken,
|
||||||
|
resume: true,
|
||||||
|
onProgress: (received, total) {
|
||||||
|
if (total > 0) {
|
||||||
|
final progress = received / total;
|
||||||
|
_updateState(url, FileDownloadProgress(progress.clamp(0.0, 1.0)));
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
_cancelTokens.remove(url);
|
||||||
|
_updateState(url, FileDownloadDone(cachePath));
|
||||||
|
return cachePath;
|
||||||
|
} catch (e) {
|
||||||
|
_cancelTokens.remove(url);
|
||||||
|
// 若已被主动取消,state 已置为 idle,不再覆盖
|
||||||
|
if (_cancelledUrls.remove(url)) return null;
|
||||||
|
_updateState(url, FileDownloadFailed(e.toString()));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 取消下载
|
||||||
|
void cancelDownload(String url) {
|
||||||
|
_cancelledUrls.add(url);
|
||||||
|
_cancelTokens[url]?.cancel('用户取消');
|
||||||
|
_cancelTokens.remove(url);
|
||||||
|
_updateState(url, const FileDownloadIdle());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取本地缓存路径(不触发下载)
|
||||||
|
///
|
||||||
|
/// 返回 null 表示尚未下载。
|
||||||
|
Future<String?> localPath(String url, String fileName) async {
|
||||||
|
final path = await _cachePath(url, fileName);
|
||||||
|
final file = File(path);
|
||||||
|
if (file.existsSync() && file.lengthSync() > 0) return path;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 内部辅助 ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
void _updateState(String url, FileDownloadState s) {
|
||||||
|
state = {...state, url: s};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 构造缓存文件路径
|
||||||
|
///
|
||||||
|
/// 格式:`{tempDir}/im_file_cache/{hash8}_{safeName}`
|
||||||
|
Future<String> _cachePath(String url, String fileName) async {
|
||||||
|
final cacheDir = Directory('${Directory.systemTemp.path}/im_file_cache');
|
||||||
|
if (!cacheDir.existsSync()) {
|
||||||
|
await cacheDir.create(recursive: true);
|
||||||
|
}
|
||||||
|
final hash = url.hashCode.toUnsigned(32).toRadixString(16).padLeft(8, '0');
|
||||||
|
final safeName = fileName.replaceAll(RegExp(r'[^\w.\-]'), '_');
|
||||||
|
return '${cacheDir.path}/${hash}_$safeName';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 文件下载管理器 Provider
|
||||||
|
final fileDownloadManagerProvider =
|
||||||
|
NotifierProvider<FileDownloadManager, Map<String, FileDownloadState>>(
|
||||||
|
FileDownloadManager.new,
|
||||||
|
);
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/core/services/file_download_manager.dart';
|
||||||
|
|
||||||
|
/// 语音消息气泡(typ = 3)
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #17 / iOS VoiceMessageBubble + AudioPlaybackService
|
||||||
|
///
|
||||||
|
/// ## 数据格式
|
||||||
|
///
|
||||||
|
/// rawContent JSON:`{ "url": "Voice/xxx.m4a", "duration": 5 }`
|
||||||
|
///
|
||||||
|
/// ## 当前实现
|
||||||
|
///
|
||||||
|
/// 下载框架已实现,播放能力待 audioplayers 包接入:
|
||||||
|
/// 1. 点击播放按钮 → 通过 FileDownloadManager 下载语音文件
|
||||||
|
/// 2. 下载完成 → TODO: AudioPlayer().play(DeviceFileSource(localPath))
|
||||||
|
///
|
||||||
|
/// ## 接入 audioplayers
|
||||||
|
///
|
||||||
|
/// ```yaml
|
||||||
|
/// # pubspec.yaml
|
||||||
|
/// dependencies:
|
||||||
|
/// audioplayers: ^6.0.0
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 解开 TODO 注释即可完成播放功能。
|
||||||
|
class AudioMessageBubble extends ConsumerWidget {
|
||||||
|
const AudioMessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.rawContent,
|
||||||
|
required this.messageId,
|
||||||
|
this.isSelf = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String rawContent;
|
||||||
|
final String messageId;
|
||||||
|
|
||||||
|
/// 是否为自己发送的消息(影响气泡方向)
|
||||||
|
final bool isSelf;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final parsed = _parseContent(rawContent);
|
||||||
|
final url = parsed['url'] as String? ?? '';
|
||||||
|
final duration = parsed['duration'] as int? ?? 0;
|
||||||
|
final fileName = 'voice_$messageId.m4a';
|
||||||
|
|
||||||
|
final downloadState =
|
||||||
|
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
|
||||||
|
const FileDownloadIdle();
|
||||||
|
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDownloading = downloadState is FileDownloadProgress;
|
||||||
|
final isDownloaded = downloadState is FileDownloadDone;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
onTap: isDownloading || url.isEmpty
|
||||||
|
? null
|
||||||
|
: () => _handleTap(ref, url, fileName, downloadState),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
|
constraints: const BoxConstraints(minWidth: 100, maxWidth: 220),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isSelf
|
||||||
|
? theme.colorScheme.primary
|
||||||
|
: theme.colorScheme.surfaceContainerHighest,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
_buildLeadingIcon(context, downloadState),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 时长 / 进度
|
||||||
|
if (isDownloading)
|
||||||
|
Text(
|
||||||
|
'${((downloadState as FileDownloadProgress).progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: theme.textTheme.bodySmall?.copyWith(
|
||||||
|
color: isSelf ? Colors.white70 : Colors.grey,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
Text(
|
||||||
|
duration > 0 ? "${duration}''" : "语音",
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: isSelf ? Colors.white : null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 波形装饰(静态占位)
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
_WaveformDecoration(isSelf: isSelf, isPlaying: isDownloaded),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildLeadingIcon(BuildContext context, FileDownloadState state) {
|
||||||
|
final isSelfBubble = isSelf;
|
||||||
|
final iconColor = isSelfBubble ? Colors.white : Theme.of(context).colorScheme.primary;
|
||||||
|
|
||||||
|
switch (state) {
|
||||||
|
case FileDownloadIdle():
|
||||||
|
return Icon(Icons.play_circle_outline, size: 28, color: iconColor);
|
||||||
|
case FileDownloadProgress(:final progress):
|
||||||
|
return SizedBox(
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: iconColor,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case FileDownloadDone():
|
||||||
|
// TODO: AudioPlaybackService 接入后区分 playing / paused 态
|
||||||
|
return Icon(Icons.pause_circle_outline, size: 28, color: iconColor);
|
||||||
|
case FileDownloadFailed():
|
||||||
|
return Icon(Icons.error_outline, size: 28, color: Colors.red.shade300);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(
|
||||||
|
WidgetRef ref,
|
||||||
|
String url,
|
||||||
|
String fileName,
|
||||||
|
FileDownloadState state,
|
||||||
|
) {
|
||||||
|
if (state is FileDownloadDone) {
|
||||||
|
// TODO: 接入 audioplayers 后播放:
|
||||||
|
// import 'package:audioplayers/audioplayers.dart';
|
||||||
|
// final player = AudioPlayer();
|
||||||
|
// await player.play(DeviceFileSource(state.localPath));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ref.read(fileDownloadManagerProvider.notifier).download(
|
||||||
|
url: url,
|
||||||
|
fileName: fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _parseContent(String raw) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 静态波形装饰(未接入音频分析时的占位)
|
||||||
|
class _WaveformDecoration extends StatelessWidget {
|
||||||
|
const _WaveformDecoration({required this.isSelf, required this.isPlaying});
|
||||||
|
|
||||||
|
final bool isSelf;
|
||||||
|
final bool isPlaying;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final baseColor = isSelf ? Colors.white54 : Colors.grey.shade400;
|
||||||
|
final activeColor =
|
||||||
|
isSelf ? Colors.white : Theme.of(context).colorScheme.primary;
|
||||||
|
final heights = [3.0, 6.0, 4.0, 8.0, 5.0, 7.0, 3.0, 6.0, 4.0];
|
||||||
|
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: heights.map((h) {
|
||||||
|
return Container(
|
||||||
|
width: 2.5,
|
||||||
|
height: h,
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isPlaying ? activeColor : baseColor,
|
||||||
|
borderRadius: BorderRadius.circular(1),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/core/services/file_download_manager.dart';
|
||||||
|
|
||||||
|
/// 文件消息气泡(typ = 6)
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #16 / iOS FileMessageBubble
|
||||||
|
///
|
||||||
|
/// 三种展示态:
|
||||||
|
/// - idle / failed → 文件名 + 大小 + 下载图标,点击触发下载
|
||||||
|
/// - downloading → 文件名 + 大小 + 线性进度条 + 百分比
|
||||||
|
/// - downloaded → 文件名 + 大小 + 打开图标,点击回调 [onTap]
|
||||||
|
///
|
||||||
|
/// ## 使用
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// FileMessageBubble(
|
||||||
|
/// url: 'Files/report.pdf',
|
||||||
|
/// fileName: 'report.pdf',
|
||||||
|
/// fileSize: 1234567,
|
||||||
|
/// onTap: (localPath) async {
|
||||||
|
/// // TODO: open_filex 接入后: await OpenFilex.open(localPath);
|
||||||
|
/// },
|
||||||
|
/// )
|
||||||
|
/// ```
|
||||||
|
class FileMessageBubble extends ConsumerWidget {
|
||||||
|
const FileMessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
required this.fileName,
|
||||||
|
required this.fileSize,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final String fileName;
|
||||||
|
final int fileSize;
|
||||||
|
|
||||||
|
/// 点击已下载文件回调;localPath 为本地路径
|
||||||
|
final void Function(String localPath)? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final downloadState =
|
||||||
|
ref.watch(fileDownloadManagerProvider.select((map) => map[url])) ??
|
||||||
|
const FileDownloadIdle();
|
||||||
|
|
||||||
|
return _FileMessageBubbleContent(
|
||||||
|
url: url,
|
||||||
|
fileName: fileName,
|
||||||
|
fileSize: fileSize,
|
||||||
|
downloadState: downloadState,
|
||||||
|
onTap: onTap,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileMessageBubbleContent extends ConsumerWidget {
|
||||||
|
const _FileMessageBubbleContent({
|
||||||
|
required this.url,
|
||||||
|
required this.fileName,
|
||||||
|
required this.fileSize,
|
||||||
|
required this.downloadState,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final String fileName;
|
||||||
|
final int fileSize;
|
||||||
|
final FileDownloadState downloadState;
|
||||||
|
final void Function(String localPath)? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final isDownloading = downloadState is FileDownloadProgress;
|
||||||
|
final isDownloaded = downloadState is FileDownloadDone;
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
onTap: isDownloading
|
||||||
|
? null
|
||||||
|
: () => _handleTap(context, ref, downloadState),
|
||||||
|
child: Container(
|
||||||
|
constraints: const BoxConstraints(minWidth: 180, maxWidth: 260),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: theme.colorScheme.surfaceContainerHighest.withOpacity(0.5),
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
// 文件类型图标
|
||||||
|
Container(
|
||||||
|
width: 36,
|
||||||
|
height: 36,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: _fileColor(fileName).withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(6),
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
_fileIcon(fileName),
|
||||||
|
size: 20,
|
||||||
|
color: _fileColor(fileName),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
// 文件名 + 大小
|
||||||
|
Flexible(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
fileName,
|
||||||
|
style: theme.textTheme.bodyMedium
|
||||||
|
?.copyWith(fontWeight: FontWeight.w500),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
_formatSize(fileSize),
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: Colors.grey),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
// 右侧状态图标
|
||||||
|
_buildTrailingIcon(context, downloadState),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// 下载进度条
|
||||||
|
if (isDownloading) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_DownloadProgressBar(
|
||||||
|
progress: (downloadState as FileDownloadProgress).progress,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
// 已下载提示
|
||||||
|
if (isDownloaded)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'已下载,点击打开',
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: theme.colorScheme.primary),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// 失败提示
|
||||||
|
if (downloadState is FileDownloadFailed)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
'下载失败,点击重试',
|
||||||
|
style: theme.textTheme.bodySmall
|
||||||
|
?.copyWith(color: theme.colorScheme.error),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildTrailingIcon(BuildContext context, FileDownloadState state) {
|
||||||
|
switch (state) {
|
||||||
|
case FileDownloadIdle():
|
||||||
|
return const Icon(Icons.download_outlined, size: 22, color: Colors.grey);
|
||||||
|
case FileDownloadProgress():
|
||||||
|
return SizedBox(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
value: state.progress,
|
||||||
|
strokeWidth: 2.5,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
case FileDownloadDone():
|
||||||
|
return Icon(
|
||||||
|
Icons.folder_open_outlined,
|
||||||
|
size: 22,
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
);
|
||||||
|
case FileDownloadFailed():
|
||||||
|
return const Icon(Icons.error_outline, size: 22, color: Colors.red);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _handleTap(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
FileDownloadState state,
|
||||||
|
) {
|
||||||
|
if (state is FileDownloadDone) {
|
||||||
|
onTap?.call(state.localPath);
|
||||||
|
// TODO: open_filex 接入后:
|
||||||
|
// import 'package:open_filex/open_filex.dart';
|
||||||
|
// await OpenFilex.open(state.localPath);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// idle / failed → 触发下载
|
||||||
|
ref.read(fileDownloadManagerProvider.notifier).download(
|
||||||
|
url: url,
|
||||||
|
fileName: fileName,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 辅助 ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
IconData _fileIcon(String name) {
|
||||||
|
final ext = name.split('.').last.toLowerCase();
|
||||||
|
return switch (ext) {
|
||||||
|
'pdf' => Icons.picture_as_pdf_outlined,
|
||||||
|
'doc' || 'docx' => Icons.description_outlined,
|
||||||
|
'xls' || 'xlsx' => Icons.table_chart_outlined,
|
||||||
|
'ppt' || 'pptx' => Icons.slideshow_outlined,
|
||||||
|
'zip' || 'rar' || '7z' => Icons.folder_zip_outlined,
|
||||||
|
'mp3' || 'aac' || 'm4a' || 'wav' => Icons.audio_file_outlined,
|
||||||
|
'mp4' || 'mov' || 'avi' || 'mkv' => Icons.video_file_outlined,
|
||||||
|
'jpg' || 'jpeg' || 'png' || 'gif' || 'webp' => Icons.image_outlined,
|
||||||
|
_ => Icons.insert_drive_file_outlined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Color _fileColor(String name) {
|
||||||
|
final ext = name.split('.').last.toLowerCase();
|
||||||
|
return switch (ext) {
|
||||||
|
'pdf' => Colors.red.shade600,
|
||||||
|
'doc' || 'docx' => Colors.blue.shade700,
|
||||||
|
'xls' || 'xlsx' => Colors.green.shade700,
|
||||||
|
'ppt' || 'pptx' => Colors.orange.shade700,
|
||||||
|
'zip' || 'rar' || '7z' => Colors.brown.shade600,
|
||||||
|
'mp3' || 'aac' || 'm4a' || 'wav' => Colors.purple.shade600,
|
||||||
|
'mp4' || 'mov' || 'avi' || 'mkv' => Colors.pink.shade600,
|
||||||
|
_ => Colors.blueGrey.shade600,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSize(int bytes) {
|
||||||
|
if (bytes <= 0) return '0 B';
|
||||||
|
if (bytes < 1024) return '$bytes B';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 下载进度条组件
|
||||||
|
class _DownloadProgressBar extends StatelessWidget {
|
||||||
|
const _DownloadProgressBar({required this.progress});
|
||||||
|
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
child: LinearProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
minHeight: 4,
|
||||||
|
backgroundColor:
|
||||||
|
Theme.of(context).colorScheme.outline.withOpacity(0.2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).toStringAsFixed(0)}%',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: Theme.of(context).colorScheme.primary,
|
||||||
|
fontSize: 10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import 'package:im_app/core/services/cdn_url_resolver.dart';
|
||||||
|
|
||||||
|
/// 视频消息气泡(typ = 4 / typ = 24)
|
||||||
|
///
|
||||||
|
/// 对应 Gitea issue #18 / iOS VideoMessageBubble
|
||||||
|
///
|
||||||
|
/// ## 数据格式
|
||||||
|
///
|
||||||
|
/// rawContent JSON:`{ "url": "Video/xxx.mp4", "thumb": "Image/thumb.jpg", "size": 12345678 }`
|
||||||
|
///
|
||||||
|
/// ## 当前实现
|
||||||
|
///
|
||||||
|
/// - 缩略图:通过 [CdnUrlResolver.resolve] 解析 thumbUrl 后用 `Image.network` 展示
|
||||||
|
/// - 播放按钮覆盖层(点击回调由父级处理)
|
||||||
|
/// - 发送进度环(出站消息上传中)
|
||||||
|
///
|
||||||
|
/// ## 接入 video_player
|
||||||
|
///
|
||||||
|
/// ```yaml
|
||||||
|
/// # pubspec.yaml
|
||||||
|
/// dependencies:
|
||||||
|
/// video_player: ^2.9.0
|
||||||
|
/// ```
|
||||||
|
///
|
||||||
|
/// 点击后 push 全屏视频路由,用 `VideoPlayerController.networkUrl(Uri.parse(resolvedUrl))` 流式播放,
|
||||||
|
/// 无需预先下载(同 iOS AVPlayer 流式播放策略)。
|
||||||
|
class VideoMessageBubble extends StatelessWidget {
|
||||||
|
const VideoMessageBubble({
|
||||||
|
super.key,
|
||||||
|
required this.rawContent,
|
||||||
|
this.uploadProgress,
|
||||||
|
this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String rawContent;
|
||||||
|
|
||||||
|
/// 上传进度(0.0~1.0),发送中时显示进度环;null 表示非发送中
|
||||||
|
final double? uploadProgress;
|
||||||
|
|
||||||
|
/// 点击回调(videoUrl, resolvedUrl)供父级处理全屏播放
|
||||||
|
final void Function(String videoUrl)? onTap;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final parsed = _parseContent(rawContent);
|
||||||
|
final videoUrl = parsed['url'] as String? ?? '';
|
||||||
|
final thumbUrl = parsed['thumb'] as String? ?? '';
|
||||||
|
final fileSize = parsed['size'] as int? ?? 0;
|
||||||
|
|
||||||
|
final resolvedThumb =
|
||||||
|
thumbUrl.isNotEmpty ? CdnUrlResolver.resolve(thumbUrl) : null;
|
||||||
|
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: videoUrl.isNotEmpty ? () => onTap?.call(videoUrl) : null,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
// 缩略图 / 占位
|
||||||
|
SizedBox(
|
||||||
|
width: 200,
|
||||||
|
height: 140,
|
||||||
|
child: resolvedThumb != null
|
||||||
|
? Image.network(
|
||||||
|
resolvedThumb,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
const _VideoThumbnailPlaceholder(),
|
||||||
|
)
|
||||||
|
: const _VideoThumbnailPlaceholder(),
|
||||||
|
),
|
||||||
|
// 上传进度环 / 播放按钮
|
||||||
|
if (uploadProgress != null)
|
||||||
|
_UploadProgressOverlay(progress: uploadProgress!)
|
||||||
|
else
|
||||||
|
_PlayButtonOverlay(hasUrl: videoUrl.isNotEmpty),
|
||||||
|
// 文件大小标签
|
||||||
|
if (fileSize > 0)
|
||||||
|
Positioned(
|
||||||
|
bottom: 4,
|
||||||
|
right: 6,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black54,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
_formatSize(fileSize),
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 10),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic> _parseContent(String raw) {
|
||||||
|
try {
|
||||||
|
return jsonDecode(raw) as Map<String, dynamic>;
|
||||||
|
} catch (_) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatSize(int bytes) {
|
||||||
|
if (bytes <= 0) return '';
|
||||||
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
||||||
|
if (bytes < 1024 * 1024 * 1024) {
|
||||||
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
||||||
|
}
|
||||||
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 缩略图占位(无 thumbUrl 或加载失败)
|
||||||
|
class _VideoThumbnailPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoThumbnailPlaceholder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
color: Colors.black87,
|
||||||
|
child: const Center(
|
||||||
|
child: Icon(Icons.video_file, size: 48, color: Colors.white54),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 播放按钮覆盖层
|
||||||
|
class _PlayButtonOverlay extends StatelessWidget {
|
||||||
|
const _PlayButtonOverlay({required this.hasUrl});
|
||||||
|
|
||||||
|
final bool hasUrl;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.black.withOpacity(0.5),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
Icons.play_arrow,
|
||||||
|
color: hasUrl ? Colors.white : Colors.white38,
|
||||||
|
size: 30,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 上传进度环覆盖层(发送中态)
|
||||||
|
class _UploadProgressOverlay extends StatelessWidget {
|
||||||
|
const _UploadProgressOverlay({required this.progress});
|
||||||
|
|
||||||
|
final double progress;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SizedBox(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
child: Stack(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
children: [
|
||||||
|
CircularProgressIndicator(
|
||||||
|
value: progress,
|
||||||
|
strokeWidth: 3,
|
||||||
|
color: Colors.white,
|
||||||
|
backgroundColor: Colors.white30,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'${(progress * 100).toInt()}%',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 10,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user