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:
pp-bot
2026-03-23 20:02:46 +09:00
parent aeeda6f059
commit 83774f5f61
6 changed files with 1096 additions and 0 deletions

View File

@@ -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(),
);
}
}

View File

@@ -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,
),
),
],
);
}
}

View File

@@ -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,
),
),
],
),
);
}
}