优化:更新我的相册项目保存过期提示
This commit is contained in:
parent
173872364a
commit
aa54b15406
@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "2.8",
|
||||
"version": "2.9",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
@ -1585,6 +1585,97 @@
|
||||
20
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "vmPs1",
|
||||
"name": "Retention notice",
|
||||
"width": "fill_container",
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 135,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#FFFBEB",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#FEF3C7",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"cornerRadius": 14,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#FBBF24"
|
||||
},
|
||||
"effect": {
|
||||
"type": "shadow",
|
||||
"shadowType": "outer",
|
||||
"color": "#F59E0B22",
|
||||
"offset": {
|
||||
"x": 0,
|
||||
"y": 2
|
||||
},
|
||||
"blur": 8
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"padding": [
|
||||
14,
|
||||
16
|
||||
],
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "NI5YU",
|
||||
"name": "row",
|
||||
"width": "fill_container",
|
||||
"gap": 12,
|
||||
"children": [
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "FDa5s",
|
||||
"name": "icon",
|
||||
"width": 18,
|
||||
"height": 18,
|
||||
"iconFontName": "timer",
|
||||
"iconFontFamily": "lucide",
|
||||
"fill": "#D97706"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "pEPL4",
|
||||
"name": "col",
|
||||
"width": "fill_container",
|
||||
"layout": "vertical",
|
||||
"gap": 6,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "zU1J8",
|
||||
"name": "hintText",
|
||||
"fill": "#78350F",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Content is valid for 24 hours. Please s ave it before it expires",
|
||||
"lineHeight": 1.4,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "600"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "g1ePM",
|
||||
@ -1631,7 +1722,72 @@
|
||||
},
|
||||
"blur": 12
|
||||
},
|
||||
"layout": "none"
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "lThZm",
|
||||
"x": 0,
|
||||
"y": 176,
|
||||
"name": "Card meta",
|
||||
"width": 165,
|
||||
"height": 72,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 180,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#00000000",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#000000D9",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 2,
|
||||
"padding": [
|
||||
2,
|
||||
12,
|
||||
6,
|
||||
12
|
||||
],
|
||||
"justifyContent": "end",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "3RTGn",
|
||||
"fill": "#FFFFFF",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Created 2026-03-30 09:12",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "osDVy",
|
||||
"fill": "#E5E7EB",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "23h 48m left",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 9,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
@ -1663,7 +1819,72 @@
|
||||
},
|
||||
"blur": 12
|
||||
},
|
||||
"layout": "none"
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "fUbKb",
|
||||
"x": 0,
|
||||
"y": 176,
|
||||
"name": "Card meta",
|
||||
"width": 165,
|
||||
"height": 72,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 180,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#00000000",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#000000D9",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 2,
|
||||
"padding": [
|
||||
2,
|
||||
12,
|
||||
6,
|
||||
12
|
||||
],
|
||||
"justifyContent": "end",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "co3Aw",
|
||||
"fill": "#FFFFFF",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Created 2026-03-29 18:05",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "Gyp3m",
|
||||
"fill": "#FDE68A",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "7h 12m left",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 9,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
@ -1705,7 +1926,72 @@
|
||||
},
|
||||
"blur": 12
|
||||
},
|
||||
"layout": "none"
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "UOw9c",
|
||||
"x": 0,
|
||||
"y": 176,
|
||||
"name": "Card meta",
|
||||
"width": 165,
|
||||
"height": 72,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 180,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#00000000",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#000000D9",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 2,
|
||||
"padding": [
|
||||
2,
|
||||
12,
|
||||
6,
|
||||
12
|
||||
],
|
||||
"justifyContent": "end",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "8hHai",
|
||||
"fill": "#FFFFFF",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Created 2026-03-29 02:40",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "wg34o",
|
||||
"fill": "#FCD34D",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "2h 5m left",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 9,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
@ -1737,7 +2023,72 @@
|
||||
},
|
||||
"blur": 12
|
||||
},
|
||||
"layout": "none"
|
||||
"layout": "none",
|
||||
"children": [
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "7D4XR",
|
||||
"x": 0,
|
||||
"y": 176,
|
||||
"name": "Card meta",
|
||||
"width": 165,
|
||||
"height": 72,
|
||||
"fill": {
|
||||
"type": "gradient",
|
||||
"gradientType": "linear",
|
||||
"enabled": true,
|
||||
"rotation": 180,
|
||||
"size": {
|
||||
"height": 1
|
||||
},
|
||||
"colors": [
|
||||
{
|
||||
"color": "#00000000",
|
||||
"position": 0
|
||||
},
|
||||
{
|
||||
"color": "#000000D9",
|
||||
"position": 1
|
||||
}
|
||||
]
|
||||
},
|
||||
"layout": "vertical",
|
||||
"gap": 2,
|
||||
"padding": [
|
||||
2,
|
||||
12,
|
||||
6,
|
||||
12
|
||||
],
|
||||
"justifyContent": "end",
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "39n7h",
|
||||
"fill": "#FFFFFF",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "Created 2026-03-28 21:08",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 10,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "kH3RX",
|
||||
"fill": "#FCA5A5",
|
||||
"textGrowth": "fixed-width",
|
||||
"width": "fill_container",
|
||||
"content": "32m left",
|
||||
"lineHeight": 1.25,
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 9,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -1946,9 +2297,11 @@
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "ZE4Rm",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"name": "topNav",
|
||||
"enabled": false,
|
||||
"width": "fill_container",
|
||||
"width": "fill_container(0)",
|
||||
"height": 56,
|
||||
"fill": "#FFFFFF",
|
||||
"effect": {
|
||||
@ -4220,6 +4573,8 @@
|
||||
{
|
||||
"type": "icon_font",
|
||||
"id": "a3uQs",
|
||||
"x": 0,
|
||||
"y": 0,
|
||||
"enabled": false,
|
||||
"width": 72,
|
||||
"height": 72,
|
||||
@ -4564,6 +4919,44 @@
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
},
|
||||
{
|
||||
"type": "text",
|
||||
"id": "mIiKO",
|
||||
"name": "verifyLabel",
|
||||
"fill": "#71717A",
|
||||
"content": "Type DELETE to confirm",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 12,
|
||||
"fontWeight": "500"
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "2XdIX",
|
||||
"name": "verifyInput",
|
||||
"width": "fill_container",
|
||||
"height": 48,
|
||||
"fill": "#FAFAFA",
|
||||
"cornerRadius": 12,
|
||||
"stroke": {
|
||||
"align": "inside",
|
||||
"thickness": 1,
|
||||
"fill": "#E4E4E7"
|
||||
},
|
||||
"layout": "vertical",
|
||||
"padding": 16,
|
||||
"children": [
|
||||
{
|
||||
"type": "text",
|
||||
"id": "GrZIb",
|
||||
"name": "placeholderText",
|
||||
"fill": "#A1A1AA",
|
||||
"content": "Type DELETE here",
|
||||
"fontFamily": "Inter",
|
||||
"fontSize": 14,
|
||||
"fontWeight": "normal"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "frame",
|
||||
"id": "0HXLh",
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:visibility_detector/visibility_detector.dart';
|
||||
|
||||
import '../../core/api/api_config.dart';
|
||||
@ -15,6 +17,94 @@ import '../home/widgets/video_card.dart';
|
||||
import 'gallery_upload_cover_store.dart';
|
||||
import 'models/gallery_task_item.dart';
|
||||
|
||||
DateTime? _galleryDateTimeFromRaw(int raw) {
|
||||
if (raw <= 0) return null;
|
||||
if (raw > 1000000000000) {
|
||||
return DateTime.fromMillisecondsSinceEpoch(raw);
|
||||
}
|
||||
return DateTime.fromMillisecondsSinceEpoch(raw * 1000);
|
||||
}
|
||||
|
||||
String? _galleryCardCreatedLine(GalleryMediaItem m) {
|
||||
final uncover = m.createTimeText?.trim();
|
||||
if (uncover != null && uncover.isNotEmpty) {
|
||||
return 'Created $uncover';
|
||||
}
|
||||
final dt = _galleryDateTimeFromRaw(m.createTime);
|
||||
if (dt == null) return null;
|
||||
String two(int n) => n.toString().padLeft(2, '0');
|
||||
return 'Created ${dt.year}-${two(dt.month)}-${two(dt.day)} '
|
||||
'${two(dt.hour)}:${two(dt.minute)}';
|
||||
}
|
||||
|
||||
String? _galleryCardRemainingLine(GalleryMediaItem m) {
|
||||
final dt = _galleryDateTimeFromRaw(m.createTime);
|
||||
if (dt == null) return null;
|
||||
final expires = dt.add(const Duration(hours: 24));
|
||||
final left = expires.difference(DateTime.now());
|
||||
if (left.isNegative) return 'Expired';
|
||||
return '${left.inHours}h ${left.inMinutes.remainder(60)}m left';
|
||||
}
|
||||
|
||||
Color _galleryCardRemainingColor(GalleryMediaItem m) {
|
||||
final dt = _galleryDateTimeFromRaw(m.createTime);
|
||||
if (dt == null) return const Color(0xFFE5E7EB);
|
||||
final expires = dt.add(const Duration(hours: 24));
|
||||
final left = expires.difference(DateTime.now());
|
||||
if (left.isNegative) return AppColors.textMuted;
|
||||
if (left.inHours < 8) return const Color(0xFFFDE68A);
|
||||
return const Color(0xFFE5E7EB);
|
||||
}
|
||||
|
||||
/// Pencil「Retention notice」(hpwBg / vmPs1)
|
||||
class _GalleryRetentionBanner extends StatelessWidget {
|
||||
const _GalleryRetentionBanner();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFFFBEB), Color(0xFFFEF3C7)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFFBBF24)),
|
||||
boxShadow: const [
|
||||
BoxShadow(
|
||||
color: Color(0x22F59E0B),
|
||||
blurRadius: 8,
|
||||
offset: Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(LucideIcons.timer, size: 18, color: Color(0xFFD97706)),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'Content is valid for 24 hours. Please save it before '
|
||||
'it expires.',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF78350F),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
height: 1.4,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Gallery screen - matches Pencil hpwBg
|
||||
class GalleryScreen extends StatefulWidget {
|
||||
const GalleryScreen({super.key, required this.isActive});
|
||||
@ -44,6 +134,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
final Set<int> _userPausedVideoIndices = {};
|
||||
final Map<int, double> _cardVisibleFraction = {};
|
||||
Timer? _visibilityDebounce;
|
||||
Timer? _remainingLabelTicker;
|
||||
/// taskId(tree) -> 本地上传封面路径(接口无封面时使用)
|
||||
Map<int, String> _localCoverPaths = {};
|
||||
|
||||
@ -74,6 +165,10 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollController.addListener(_onScroll);
|
||||
_remainingLabelTicker =
|
||||
Timer.periodic(const Duration(minutes: 1), (_) {
|
||||
if (mounted) setState(() {});
|
||||
});
|
||||
if (widget.isActive) _loadTasks(refresh: true);
|
||||
}
|
||||
|
||||
@ -90,6 +185,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_remainingLabelTicker?.cancel();
|
||||
_visibilityDebounce?.cancel();
|
||||
_scrollController.dispose();
|
||||
super.dispose();
|
||||
@ -184,6 +280,49 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
setState(() => _localCoverPaths = paths);
|
||||
}
|
||||
|
||||
void _showGalleryMessage(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(message)),
|
||||
);
|
||||
}
|
||||
|
||||
/// pending / 生成中 → 进度页;finished + 远程 URL → 结果下载页;其它状态 SnackBar 提示
|
||||
void _onGalleryMediaTap(GalleryMediaItem media) {
|
||||
final raw = media.listingRaw;
|
||||
final disp = media.listingDisplay;
|
||||
if (galleryListingIsInProgress(raw, disp)) {
|
||||
final tid = media.taskId;
|
||||
if (tid == null || tid <= 0) {
|
||||
_showGalleryMessage('Cannot open progress: missing task id.');
|
||||
return;
|
||||
}
|
||||
final path = _localCoverPaths[tid];
|
||||
final pathOk = path != null &&
|
||||
path.isNotEmpty &&
|
||||
File(path).existsSync();
|
||||
Navigator.of(context).pushNamed(
|
||||
'/progress',
|
||||
arguments: {
|
||||
'taskId': tid,
|
||||
if (pathOk) 'imagePath': path,
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (galleryListingIsFinishedSuccess(raw, disp)) {
|
||||
if (!galleryMediaHasRemoteUrl(media)) {
|
||||
_showGalleryMessage(
|
||||
'Media is not ready yet. Please wait or pull to refresh.',
|
||||
);
|
||||
return;
|
||||
}
|
||||
Navigator.of(context).pushNamed('/result', arguments: media);
|
||||
return;
|
||||
}
|
||||
_showGalleryMessage(galleryListingBlockedHint(raw, disp));
|
||||
}
|
||||
|
||||
Future<void> _loadTasks({bool refresh = true}) async {
|
||||
if (refresh) {
|
||||
setState(() {
|
||||
@ -270,7 +409,7 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
appBar: const PreferredSize(
|
||||
preferredSize: Size.fromHeight(56),
|
||||
child: TopNavBar(
|
||||
title: 'My Gallery',
|
||||
title: 'Gallery',
|
||||
),
|
||||
),
|
||||
body: _loading
|
||||
@ -301,11 +440,27 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _loadTasks(refresh: true),
|
||||
child: _gridItems.isEmpty && !_loading
|
||||
? SingleChildScrollView(
|
||||
? CustomScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
cacheExtent: 800,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.screenPadding,
|
||||
16,
|
||||
),
|
||||
child: const _GalleryRetentionBanner(),
|
||||
),
|
||||
),
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight - 100,
|
||||
height: max(0.0, constraints.maxHeight - 220),
|
||||
child: const Center(
|
||||
child: Text(
|
||||
'No images yet',
|
||||
@ -315,19 +470,35 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: GridView.builder(
|
||||
: CustomScrollView(
|
||||
physics:
|
||||
const AlwaysScrollableScrollPhysics(),
|
||||
controller: _scrollController,
|
||||
cacheExtent: 800,
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.xl,
|
||||
AppSpacing.screenPadding,
|
||||
16,
|
||||
),
|
||||
child: const _GalleryRetentionBanner(),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
AppSpacing.screenPadding,
|
||||
0,
|
||||
AppSpacing.screenPadding,
|
||||
AppSpacing.screenPaddingLarge +
|
||||
(_loadingMore ? 48.0 : 0),
|
||||
),
|
||||
sliver: SliverGrid(
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 2,
|
||||
@ -335,9 +506,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
mainAxisSpacing: AppSpacing.xl,
|
||||
crossAxisSpacing: AppSpacing.xl,
|
||||
),
|
||||
itemCount: _gridItems.length +
|
||||
(_loadingMore ? 1 : 0),
|
||||
itemBuilder: (context, index) {
|
||||
delegate: SliverChildBuilderDelegate(
|
||||
(context, index) {
|
||||
if (index >= _gridItems.length) {
|
||||
return const Center(
|
||||
child: Padding(
|
||||
@ -345,7 +515,8 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
child: SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
),
|
||||
@ -353,18 +524,18 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
);
|
||||
}
|
||||
final media = _gridItems[index];
|
||||
final coverSpecs = _galleryCardCover(media);
|
||||
final coverSpecs =
|
||||
_galleryCardCover(media);
|
||||
final videoUrl = media.videoUrl;
|
||||
final hasVideo =
|
||||
videoUrl != null && videoUrl.isNotEmpty;
|
||||
final hasVideo = videoUrl != null &&
|
||||
videoUrl.isNotEmpty;
|
||||
final detectorKey =
|
||||
'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}';
|
||||
void openResult() {
|
||||
Navigator.of(context).pushNamed(
|
||||
'/result',
|
||||
arguments: media,
|
||||
);
|
||||
}
|
||||
final created =
|
||||
_galleryCardCreatedLine(media);
|
||||
final remaining =
|
||||
_galleryCardRemainingLine(
|
||||
media);
|
||||
|
||||
return VisibilityDetector(
|
||||
key: ValueKey(detectorKey),
|
||||
@ -378,27 +549,48 @@ class _GalleryScreenState extends State<GalleryScreen> {
|
||||
key: ValueKey(detectorKey),
|
||||
imageUrl: coverSpecs.imageUrl,
|
||||
cover: coverSpecs.cover,
|
||||
videoUrl: hasVideo ? videoUrl : null,
|
||||
videoUrl: hasVideo
|
||||
? videoUrl
|
||||
: null,
|
||||
credits: '50',
|
||||
showCreditsBadge: false,
|
||||
showBottomGenerateButton: false,
|
||||
showBottomGenerateButton:
|
||||
false,
|
||||
cardMetaCreatedText:
|
||||
created,
|
||||
cardMetaRemainingText:
|
||||
remaining,
|
||||
cardMetaRemainingColor:
|
||||
_galleryCardRemainingColor(
|
||||
media),
|
||||
topRightStatusText:
|
||||
media.listingDisplay,
|
||||
isActive: widget.isActive &&
|
||||
hasVideo &&
|
||||
_visibleVideoIndices
|
||||
.contains(index) &&
|
||||
!_userPausedVideoIndices
|
||||
.contains(index),
|
||||
onPlayRequested: () => setState(() =>
|
||||
onPlayRequested: () =>
|
||||
setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.remove(index)),
|
||||
onStopRequested: () => setState(() =>
|
||||
onStopRequested: () =>
|
||||
setState(() =>
|
||||
_userPausedVideoIndices
|
||||
.add(index)),
|
||||
onGenerateSimilar: openResult,
|
||||
onGenerateSimilar: () =>
|
||||
_onGalleryMediaTap(media),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
childCount: _gridItems.length +
|
||||
(_loadingMore ? 1 : 0),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -1,15 +1,175 @@
|
||||
/// listing 为数字时的英文文案(兼容旧接口):1 队列中 | 2 处理中 | …
|
||||
String _galleryListingLabelEnglish(int listing) {
|
||||
switch (listing) {
|
||||
case 1:
|
||||
return 'Queued';
|
||||
case 2:
|
||||
return 'Processing';
|
||||
case 3:
|
||||
return 'Completed';
|
||||
case 4:
|
||||
return 'Timed out';
|
||||
case 5:
|
||||
return 'Error';
|
||||
case 6:
|
||||
return 'Aborted';
|
||||
case 0:
|
||||
return 'Pending';
|
||||
default:
|
||||
return 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
/// 接口 `listing`:字符串(如 finished)原样展示;纯数字字符串或 int 仍走英文映射
|
||||
String listingDisplayFromApi(dynamic raw) {
|
||||
if (raw == null) return '';
|
||||
if (raw is int) return _galleryListingLabelEnglish(raw);
|
||||
if (raw is num) return _galleryListingLabelEnglish(raw.toInt());
|
||||
final s = raw.toString().trim();
|
||||
if (s.isEmpty) return '';
|
||||
final asInt = int.tryParse(s);
|
||||
if (asInt != null && s == asInt.toString()) {
|
||||
return _galleryListingLabelEnglish(asInt);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
bool galleryMediaHasRemoteUrl(GalleryMediaItem m) {
|
||||
bool http(String? x) {
|
||||
if (x == null) return false;
|
||||
final t = x.trim();
|
||||
return t.startsWith('http://') || t.startsWith('https://');
|
||||
}
|
||||
|
||||
return http(m.imageUrl) || http(m.videoUrl);
|
||||
}
|
||||
|
||||
/// 生成中:pending / queued / processing;兼容数字 0·1·2
|
||||
bool galleryListingIsInProgress(dynamic raw, String display) {
|
||||
final d = display.trim().toLowerCase();
|
||||
if (d == 'pending' ||
|
||||
d == 'queued' ||
|
||||
d == 'processing' ||
|
||||
d == 'in progress' ||
|
||||
d == 'running') {
|
||||
return true;
|
||||
}
|
||||
if (raw != null) {
|
||||
if (raw is int && (raw == 0 || raw == 1 || raw == 2)) return true;
|
||||
if (raw is num) {
|
||||
final v = raw.toInt();
|
||||
if (v == 0 || v == 1 || v == 2) return true;
|
||||
}
|
||||
final s = raw.toString().trim().toLowerCase();
|
||||
if (s == 'pending' ||
|
||||
s == 'queued' ||
|
||||
s == 'processing' ||
|
||||
s == 'in_progress' ||
|
||||
s == 'in progress' ||
|
||||
s == 'running') {
|
||||
return true;
|
||||
}
|
||||
final n = int.tryParse(s);
|
||||
if (n != null && (n == 0 || n == 1 || n == 2)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 可去结果页:finished / completed;兼容数字 3
|
||||
bool galleryListingIsFinishedSuccess(dynamic raw, String display) {
|
||||
final d = display.trim().toLowerCase();
|
||||
if (d == 'finished' ||
|
||||
d == 'completed' ||
|
||||
d == 'complete' ||
|
||||
d == 'success' ||
|
||||
d == 'done') {
|
||||
return true;
|
||||
}
|
||||
if (raw != null) {
|
||||
if (raw is int && raw == 3) return true;
|
||||
if (raw is num && raw.toInt() == 3) return true;
|
||||
final s = raw.toString().trim().toLowerCase();
|
||||
if (s == 'finished' ||
|
||||
s == 'completed' ||
|
||||
s == 'complete' ||
|
||||
s == 'success' ||
|
||||
s == 'done') {
|
||||
return true;
|
||||
}
|
||||
if (int.tryParse(s) == 3) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// 不可跳转结果/进度时的英文提示
|
||||
String galleryListingBlockedHint(dynamic raw, String display) {
|
||||
int? code;
|
||||
if (raw is int) {
|
||||
code = raw;
|
||||
} else if (raw is num) {
|
||||
code = raw.toInt();
|
||||
} else if (raw != null) {
|
||||
final s = raw.toString().trim().toLowerCase();
|
||||
if (s == 'timeout' || s == 'timed out' || s == 'timed_out') {
|
||||
code = 4;
|
||||
} else if (s == 'error' || s == 'failed' || s == 'failure') {
|
||||
code = 5;
|
||||
} else if (s == 'aborted' || s == 'cancelled' || s == 'canceled') {
|
||||
code = 6;
|
||||
} else {
|
||||
code = int.tryParse(s);
|
||||
}
|
||||
}
|
||||
switch (code) {
|
||||
case 4:
|
||||
return 'This task has timed out.';
|
||||
case 5:
|
||||
return 'This task failed. Please try again.';
|
||||
case 6:
|
||||
return 'This task was cancelled.';
|
||||
default:
|
||||
final low = display.trim().toLowerCase();
|
||||
if (low.contains('timeout') || low.contains('timed out')) {
|
||||
return 'This task has timed out.';
|
||||
}
|
||||
if (low.contains('error') || low.contains('fail')) {
|
||||
return 'This task failed. Please try again.';
|
||||
}
|
||||
if (low.contains('abort') || low.contains('cancel')) {
|
||||
return 'This task was cancelled.';
|
||||
}
|
||||
return 'This item is not available yet.';
|
||||
}
|
||||
}
|
||||
|
||||
/// 媒体项:reconfigure=imgUrl,类型由 reconnect(imgType) 决定:0=视频,1=图片,其他当图片
|
||||
class GalleryMediaItem {
|
||||
const GalleryMediaItem({
|
||||
GalleryMediaItem({
|
||||
this.imageUrl,
|
||||
this.videoUrl,
|
||||
this.taskId,
|
||||
}) : assert(imageUrl != null || videoUrl != null);
|
||||
this.createTime = 0,
|
||||
this.createTimeText,
|
||||
this.listingDisplay = '',
|
||||
this.listingRaw,
|
||||
}) : assert(
|
||||
(imageUrl != null && imageUrl.isNotEmpty) ||
|
||||
(videoUrl != null && videoUrl.isNotEmpty) ||
|
||||
(taskId != null && taskId > 0),
|
||||
);
|
||||
|
||||
final String? imageUrl;
|
||||
final String? videoUrl;
|
||||
/// 与列表项 `tree` 一致,用于匹配本地上传封面缓存
|
||||
final int? taskId;
|
||||
/// 任务创建时间戳(discover,秒或毫秒)
|
||||
final int createTime;
|
||||
/// 服务端格式化创建时间(uncover),有则优先展示
|
||||
final String? createTimeText;
|
||||
/// 任务状态展示文案(接口 listing:字符串直出或数字映射为英文)
|
||||
final String listingDisplay;
|
||||
/// 接口原始 `listing`(字符串/数字),用于与 [listingDisplay] 一起做跳转判断
|
||||
final dynamic listingRaw;
|
||||
|
||||
/// reconnect==0 为视频,1 或其他为图片
|
||||
bool get isVideo =>
|
||||
@ -36,13 +196,24 @@ class GalleryTaskItem {
|
||||
final treeRaw = json['tree'] as num?;
|
||||
final treeId = treeRaw?.toInt() ?? 0;
|
||||
final itemTaskId = treeId > 0 ? treeId : null;
|
||||
final createTime = (json['discover'] as num?)?.toInt() ?? 0;
|
||||
final createTimeText = json['uncover'] as String?;
|
||||
final listingRaw = json['listing'];
|
||||
final listingDisplay = listingDisplayFromApi(listingRaw);
|
||||
final downsample = json['downsample'] as List<dynamic>? ?? [];
|
||||
final items = <GalleryMediaItem>[];
|
||||
// 只取downsample的array[0]
|
||||
if (downsample.isNotEmpty) {
|
||||
final first = downsample[0];
|
||||
if (first is String) {
|
||||
items.add(GalleryMediaItem(imageUrl: first, taskId: itemTaskId));
|
||||
if (first is String && first.trim().isNotEmpty) {
|
||||
items.add(GalleryMediaItem(
|
||||
imageUrl: first,
|
||||
taskId: itemTaskId,
|
||||
createTime: createTime,
|
||||
createTimeText: createTimeText,
|
||||
listingDisplay: listingDisplay,
|
||||
listingRaw: listingRaw,
|
||||
));
|
||||
} else if (first is Map<String, dynamic>) {
|
||||
final reconfigure = first['reconfigure'] as String?;
|
||||
if (reconfigure != null && reconfigure.isNotEmpty) {
|
||||
@ -56,17 +227,35 @@ class GalleryTaskItem {
|
||||
items.add(GalleryMediaItem(
|
||||
videoUrl: reconfigure,
|
||||
taskId: itemTaskId,
|
||||
createTime: createTime,
|
||||
createTimeText: createTimeText,
|
||||
listingDisplay: listingDisplay,
|
||||
listingRaw: listingRaw,
|
||||
));
|
||||
} else {
|
||||
items.add(GalleryMediaItem(
|
||||
imageUrl: reconfigure,
|
||||
taskId: itemTaskId,
|
||||
createTime: createTime,
|
||||
createTimeText: createTimeText,
|
||||
listingDisplay: listingDisplay,
|
||||
listingRaw: listingRaw,
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
// 后续下标均忽略
|
||||
}
|
||||
// downsample 无可用 URL 时仍占一格:用 tree 匹配本地封面缓存
|
||||
if (items.isEmpty && itemTaskId != null && itemTaskId > 0) {
|
||||
items.add(GalleryMediaItem(
|
||||
taskId: itemTaskId,
|
||||
createTime: createTime,
|
||||
createTimeText: createTimeText,
|
||||
listingDisplay: listingDisplay,
|
||||
listingRaw: listingRaw,
|
||||
));
|
||||
}
|
||||
// for (final item in downsample) {
|
||||
// if (item is String) {
|
||||
// items.add(GalleryMediaItem(imageUrl: item));
|
||||
|
||||
@ -28,6 +28,10 @@ class VideoCard extends StatefulWidget {
|
||||
required this.onPlayRequested,
|
||||
required this.onStopRequested,
|
||||
this.onGenerateSimilar,
|
||||
this.cardMetaCreatedText,
|
||||
this.cardMetaRemainingText,
|
||||
this.cardMetaRemainingColor,
|
||||
this.topRightStatusText,
|
||||
});
|
||||
|
||||
final String imageUrl;
|
||||
@ -42,6 +46,13 @@ class VideoCard extends StatefulWidget {
|
||||
final VoidCallback onPlayRequested;
|
||||
final VoidCallback onStopRequested;
|
||||
final VoidCallback? onGenerateSimilar;
|
||||
/// Gallery 卡片底部渐变区:第一行「Created …」
|
||||
final String? cardMetaCreatedText;
|
||||
/// Gallery:第二行剩余时间;为 null 或空则不渲染第二行
|
||||
final String? cardMetaRemainingText;
|
||||
final Color? cardMetaRemainingColor;
|
||||
/// Gallery:任务状态英文,显示在卡片右上角(与积分角标二选一场景下由父组件控制)
|
||||
final String? topRightStatusText;
|
||||
|
||||
@override
|
||||
State<VideoCard> createState() => _VideoCardState();
|
||||
@ -369,6 +380,66 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.cardMetaCreatedText != null &&
|
||||
widget.cardMetaCreatedText!.isNotEmpty)
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
height: 72,
|
||||
child: IgnorePointer(
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Colors.transparent,
|
||||
Color(0xD9000000),
|
||||
],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 2, 12, 6),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
widget.cardMetaCreatedText!,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w500,
|
||||
height: 1.25,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
if (widget.cardMetaRemainingText != null &&
|
||||
widget.cardMetaRemainingText!.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
widget.cardMetaRemainingText!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: widget.cardMetaRemainingColor ??
|
||||
const Color(0xFFE5E7EB),
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.normal,
|
||||
height: 1.25,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showCreditsBadge)
|
||||
Positioned(
|
||||
top: 12,
|
||||
@ -406,6 +477,39 @@ class _VideoCardState extends State<VideoCard> {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.topRightStatusText != null &&
|
||||
widget.topRightStatusText!.trim().isNotEmpty)
|
||||
Positioned(
|
||||
top: 12,
|
||||
right: 12,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: constraints.maxWidth * 0.58,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.overlayDark,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Text(
|
||||
widget.topRightStatusText!.trim(),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
color: AppColors.surface,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontFamily: 'Inter',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (widget.showBottomGenerateButton)
|
||||
Positioned(
|
||||
bottom: 16,
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
name: pets_hero_ai
|
||||
description: PetsHero AI Application.
|
||||
publish_to: 'none'
|
||||
version: 1.1.15+26
|
||||
version: 1.1.16+27
|
||||
|
||||
environment:
|
||||
sdk: '>=3.0.0 <4.0.0'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user