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:
@@ -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