355 lines
13 KiB
Dart
355 lines
13 KiB
Dart
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 比例,圆角 20,Download 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';
|
||
}
|