From 83774f5f6160499404e2517355e39c54e3ab872a Mon Sep 17 00:00:00 2001 From: pp-bot Date: Mon, 23 Mar 2026 20:02:46 +0900 Subject: [PATCH] =?UTF-8?q?feat(file-download):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E4=B8=8B=E8=BD=BD=E5=85=A8=E9=87=8F=E5=AE=9E=E7=8E=B0=20(#14~#?= =?UTF-8?q?18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CdnUrlResolver: 相对路径/S3 URL 统一解析为 apiBaseUrl 代理链路 (#14) - FileDownloadManager: Notifier>,四态(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 --- Doc/file_download_architecture.md | 163 ++++++++++ .../lib/core/services/cdn_url_resolver.dart | 72 +++++ .../core/services/file_download_manager.dart | 183 +++++++++++ .../view/widgets/audio_message_bubble.dart | 194 ++++++++++++ .../view/widgets/file_message_bubble.dart | 290 ++++++++++++++++++ .../view/widgets/video_message_bubble.dart | 194 ++++++++++++ 6 files changed, 1096 insertions(+) create mode 100644 Doc/file_download_architecture.md create mode 100644 apps/im_app/lib/core/services/cdn_url_resolver.dart create mode 100644 apps/im_app/lib/core/services/file_download_manager.dart create mode 100644 apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart create mode 100644 apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart create mode 100644 apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart diff --git a/Doc/file_download_architecture.md b/Doc/file_download_architecture.md new file mode 100644 index 0000000..46d2a99 --- /dev/null +++ b/Doc/file_download_architecture.md @@ -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> + └─ NetworksSdkApi (via networkSdkApiProvider) +``` + +与 Settings ViewModel 一致,使用手动 `Notifier` + `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)); +``` diff --git a/apps/im_app/lib/core/services/cdn_url_resolver.dart b/apps/im_app/lib/core/services/cdn_url_resolver.dart new file mode 100644 index 0000000..51c0b54 --- /dev/null +++ b/apps/im_app/lib/core/services/cdn_url_resolver.dart @@ -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; + } + } +} diff --git a/apps/im_app/lib/core/services/file_download_manager.dart b/apps/im_app/lib/core/services/file_download_manager.dart new file mode 100644 index 0000000..6ac53f8 --- /dev/null +++ b/apps/im_app/lib/core/services/file_download_manager.dart @@ -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> { + /// 进行中的取消令牌,key = 原始 URL + final _cancelTokens = {}; + + /// 已主动取消的 URL,防止 catch 块将其状态覆盖为 failed + final _cancelledUrls = {}; + + @override + Map build() => {}; + + // ── 公开 API ──────────────────────────────────────────────────────────────── + + /// 下载文件(幂等) + /// + /// [url] 原始 CDN 路径(相对路径或完整 URL),由 [CdnUrlResolver.resolve] 处理。 + /// [fileName] 原始文件名(用于缓存命名和 MIME 类型推断)。 + /// + /// 返回本地缓存路径(已缓存时立即返回,新下载完成后返回)。 + Future 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 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 _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.new, +); diff --git a/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart new file mode 100644 index 0000000..84e3135 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/audio_message_bubble.dart @@ -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 _parseContent(String raw) { + try { + return jsonDecode(raw) as Map; + } 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(), + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart new file mode 100644 index 0000000..1f45707 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/file_message_bubble.dart @@ -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, + ), + ), + ], + ); + } +} diff --git a/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart b/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart new file mode 100644 index 0000000..2f79c24 --- /dev/null +++ b/apps/im_app/lib/features/chat/view/widgets/video_message_bubble.dart @@ -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 _parseContent(String raw) { + try { + return jsonDecode(raw) as Map; + } 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, + ), + ), + ], + ), + ); + } +}