FunyMeeAI/lib/features/history/widgets/history_grid_card.dart

355 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:client_proxy_framework/client_proxy_framework.dart';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:intl/intl.dart';
import '../../../design/pencil_theme.dart';
final _historyCardDateFormat = DateFormat('MMM d, yyyy');
/// WBRp4 单张卡片171×182 比例,圆角 20Download pill。
///
/// [MyTaskItem.resultUrl] 为生成结果地址(逻辑字段 `imgUrl`,线网常为 `boost`),可能是图片或视频。
class HistoryGridCard extends StatelessWidget {
const HistoryGridCard({
super.key,
required this.item,
this.localCoverPath,
this.onTap,
this.onDownload,
this.onDelete,
this.showDownload = true,
this.statusLabel = '',
this.isDownloading = false,
this.isDeleting = false,
});
final MyTaskItem item;
final String? localCoverPath;
final VoidCallback? onTap;
final VoidCallback? onDownload;
final VoidCallback? onDelete;
/// 仅完成态且可下载时显示 Download否则展示 [statusLabel](与 app_client 图库一致)。
final bool showDownload;
final String statusLabel;
/// 保存到相册进行中pill 显示加载,直至 [onDownload] 结束。
final bool isDownloading;
final bool isDeleting;
@override
Widget build(BuildContext context) {
final url = item.resultUrl?.trim() ?? '';
final dateLabel = _formatCardDate(item.createTime);
final remainder = _remainderLabel(item.createTime);
return LayoutBuilder(
builder: (context, c) {
final w = c.maxWidth;
final h = w * (182 / 171);
return SizedBox(
width: w,
height: h,
child: GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
top: 0,
width: w,
height: h,
child: ClipRRect(
borderRadius: BorderRadius.circular(20),
child: _HistoryThumb(
networkUrl: url.isNotEmpty ? url : null,
localPath: localCoverPath,
),
),
),
Positioned(
left: 0,
right: 0,
top: 0,
height: 64,
child: ClipRRect(
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.55),
Colors.black.withValues(alpha: 0),
],
),
),
),
),
),
Positioned(
left: 8,
top: 6,
right: 6,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
dateLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 9,
fontWeight: FontWeight.w500,
color: Colors.white.withValues(alpha: 0.95),
shadows: const [
Shadow(
blurRadius: 4,
color: Color(0x40000000),
offset: Offset(0, 1),
),
],
),
),
const SizedBox(height: 2),
Text(
remainder,
style: GoogleFonts.inter(
fontSize: 9,
fontWeight: FontWeight.w600,
color: PencilTheme.underlineGold,
shadows: const [
Shadow(
blurRadius: 4,
color: Color(0x40000000),
offset: Offset(0, 1),
),
],
),
),
],
),
),
if (onDelete != null) ...[
const SizedBox(width: 4),
Material(
color: Colors.black.withValues(alpha: 0.4),
shape: const CircleBorder(),
child: IconButton(
onPressed: isDeleting ? null : onDelete,
padding: EdgeInsets.zero,
constraints: const BoxConstraints(
minWidth: 30,
minHeight: 30,
),
icon: isDeleting
? SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white.withValues(alpha: 0.95),
),
)
: Icon(
Icons.delete_outline_rounded,
size: 17,
color: Colors.white.withValues(alpha: 0.95),
),
style: IconButton.styleFrom(
visualDensity: VisualDensity.compact,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
),
),
],
],
),
),
Positioned(
right: 8,
bottom: 8,
child: showDownload
? Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(21),
side: const BorderSide(
color: PencilTheme.downloadPillBorder,
),
),
child: AbsorbPointer(
absorbing: isDownloading,
child: InkWell(
onTap: onDownload,
borderRadius: BorderRadius.circular(21),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
child: isDownloading
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme.downloadPillInk,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Download',
style: TextStyle(
fontFamily: 'BonheurRoyale',
fontSize: 12,
color: PencilTheme.downloadPillInk,
),
),
const SizedBox(width: 4),
Icon(
Icons.download_rounded,
size: 10,
color: PencilTheme.downloadPillInk,
),
],
),
),
),
),
)
: Material(
color: Colors.black.withValues(alpha: 0.45),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(21),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 6,
),
child: Text(
statusLabel.isNotEmpty ? statusLabel : '',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
),
),
),
],
),
),
);
},
);
}
}
bool _urlLooksLikeVideo(String url) {
if (url.isEmpty) return false;
final lower = url.toLowerCase();
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi'];
return hints.any((h) => lower.contains(h));
}
/// 图片:网络图;视频:仅封面(优先本地上传缩略图),不播放视频。
class _HistoryThumb extends StatelessWidget {
const _HistoryThumb({
this.networkUrl,
this.localPath,
});
final String? networkUrl;
final String? localPath;
@override
Widget build(BuildContext context) {
final net = networkUrl?.trim() ?? '';
if (net.isNotEmpty) {
final uri = Uri.tryParse(net);
if (uri != null &&
(uri.isScheme('http') || uri.isScheme('https'))) {
if (_urlLooksLikeVideo(net)) {
if (localPath != null && localPath!.isNotEmpty) {
return Image.file(File(localPath!), fit: BoxFit.cover);
}
return const _VideoCoverPlaceholder();
}
return CachedNetworkImage(imageUrl: net, fit: BoxFit.cover);
}
}
if (localPath != null && localPath!.isNotEmpty) {
return Image.file(File(localPath!), fit: BoxFit.cover);
}
return Container(color: PencilTheme.cardThumbBg);
}
}
/// 无本地封面时的视频任务占位(不拉起解码器)。
class _VideoCoverPlaceholder extends StatelessWidget {
const _VideoCoverPlaceholder();
@override
Widget build(BuildContext context) {
return ColoredBox(
color: PencilTheme.cardThumbBg,
child: Center(
child: Icon(
Icons.videocam_outlined,
size: 40,
color: PencilTheme.inkSoft.withValues(alpha: 0.45),
),
),
);
}
}
/// 与 [MyTaskItem.createTime] 一致:毫秒时间戳、秒级时间戳或 ISO 字符串。
DateTime? _parseCreateTime(String? raw) {
if (raw == null || raw.isEmpty) return null;
final asInt = int.tryParse(raw.trim());
if (asInt != null) {
var ms = asInt;
if (asInt < 2000000000) ms = asInt * 1000;
return DateTime.fromMillisecondsSinceEpoch(ms);
}
return DateTime.tryParse(raw.trim());
}
String _formatCardDate(String? raw) {
final dt = _parseCreateTime(raw);
if (dt == null) return '';
return _historyCardDateFormat.format(dt);
}
String _remainderLabel(String? createTimeRaw) {
final created = _parseCreateTime(createTimeRaw);
if (created == null) return '';
final deadline = created.add(const Duration(hours: 24));
final left = deadline.difference(DateTime.now());
if (left.isNegative) return 'Expired';
final h = left.inHours;
final m = left.inMinutes.remainder(60);
return '${h}h ${m.toString().padLeft(2, '0')}m left';
}