From aa54b154068f3dccdea518e224dfb119f1f2d081 Mon Sep 17 00:00:00 2001 From: ivan Date: Thu, 2 Apr 2026 16:47:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=EF=BC=9A=E6=9B=B4=E6=96=B0?= =?UTF-8?q?=E6=88=91=E7=9A=84=E7=9B=B8=E5=86=8C=E9=A1=B9=E7=9B=AE=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E8=BF=87=E6=9C=9F=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- design/pencil-app-client.pen | 405 +++++++++++++++++- lib/features/gallery/gallery_screen.dart | 370 ++++++++++++---- .../gallery/models/gallery_task_item.dart | 197 ++++++++- lib/features/home/widgets/video_card.dart | 104 +++++ pubspec.yaml | 2 +- 5 files changed, 978 insertions(+), 100 deletions(-) diff --git a/design/pencil-app-client.pen b/design/pencil-app-client.pen index bda5a04..65cf04b 100644 --- a/design/pencil-app-client.pen +++ b/design/pencil-app-client.pen @@ -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", diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index c8b2f3d..2faa634 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -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 { final Set _userPausedVideoIndices = {}; final Map _cardVisibleFraction = {}; Timer? _visibilityDebounce; + Timer? _remainingLabelTicker; /// taskId(tree) -> 本地上传封面路径(接口无封面时使用) Map _localCoverPaths = {}; @@ -74,6 +165,10 @@ class _GalleryScreenState extends State { 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 { @override void dispose() { + _remainingLabelTicker?.cancel(); _visibilityDebounce?.cancel(); _scrollController.dispose(); super.dispose(); @@ -184,6 +280,49 @@ class _GalleryScreenState extends State { 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 _loadTasks({bool refresh = true}) async { if (refresh) { setState(() { @@ -270,7 +409,7 @@ class _GalleryScreenState extends State { appBar: const PreferredSize( preferredSize: Size.fromHeight(56), child: TopNavBar( - title: 'My Gallery', + title: 'Gallery', ), ), body: _loading @@ -301,104 +440,157 @@ class _GalleryScreenState extends State { child: RefreshIndicator( onRefresh: () => _loadTasks(refresh: true), child: _gridItems.isEmpty && !_loading - ? SingleChildScrollView( - physics: - const AlwaysScrollableScrollPhysics(), - child: SizedBox( - height: constraints.maxHeight - 100, - child: const Center( - child: Text( - 'No images yet', - style: TextStyle( - color: AppColors.textSecondary, - ), - ), - ), - ), - ) - : GridView.builder( + ? CustomScrollView( physics: const AlwaysScrollableScrollPhysics(), controller: _scrollController, cacheExtent: 800, - padding: EdgeInsets.fromLTRB( - AppSpacing.screenPadding, - AppSpacing.xl, - AppSpacing.screenPadding, - AppSpacing.screenPaddingLarge + - (_loadingMore ? 48.0 : 0), - ), - gridDelegate: - const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - childAspectRatio: 165 / 248, - mainAxisSpacing: AppSpacing.xl, - crossAxisSpacing: AppSpacing.xl, - ), - itemCount: _gridItems.length + - (_loadingMore ? 1 : 0), - itemBuilder: (context, index) { - if (index >= _gridItems.length) { - return const Center( - child: Padding( - padding: EdgeInsets.all(16), - child: SizedBox( - width: 24, - height: 24, - child: CircularProgressIndicator( - strokeWidth: 2, + slivers: [ + SliverToBoxAdapter( + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.screenPadding, + AppSpacing.xl, + AppSpacing.screenPadding, + 16, + ), + child: const _GalleryRetentionBanner(), + ), + ), + SliverFillRemaining( + hasScrollBody: false, + child: SizedBox( + height: max(0.0, constraints.maxHeight - 220), + child: const Center( + child: Text( + 'No images yet', + style: TextStyle( + color: AppColors.textSecondary, ), ), ), - ); - } - final media = _gridItems[index]; - final coverSpecs = _galleryCardCover(media); - final videoUrl = media.videoUrl; - final hasVideo = - videoUrl != null && videoUrl.isNotEmpty; - final detectorKey = - 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; - void openResult() { - Navigator.of(context).pushNamed( - '/result', - arguments: media, - ); - } + ), + ), + ], + ) + : 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(), + ), + ), + SliverPadding( + padding: EdgeInsets.fromLTRB( + AppSpacing.screenPadding, + 0, + AppSpacing.screenPadding, + AppSpacing.screenPaddingLarge + + (_loadingMore ? 48.0 : 0), + ), + sliver: SliverGrid( + gridDelegate: + const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + childAspectRatio: 165 / 248, + mainAxisSpacing: AppSpacing.xl, + crossAxisSpacing: AppSpacing.xl, + ), + delegate: SliverChildBuilderDelegate( + (context, index) { + if (index >= _gridItems.length) { + return const Center( + child: Padding( + padding: EdgeInsets.all(16), + child: SizedBox( + width: 24, + height: 24, + child: + CircularProgressIndicator( + strokeWidth: 2, + ), + ), + ), + ); + } + final media = _gridItems[index]; + final coverSpecs = + _galleryCardCover(media); + final videoUrl = media.videoUrl; + final hasVideo = videoUrl != null && + videoUrl.isNotEmpty; + final detectorKey = + 'gallery_${index}_${videoUrl ?? media.imageUrl ?? ''}'; + final created = + _galleryCardCreatedLine(media); + final remaining = + _galleryCardRemainingLine( + media); - return VisibilityDetector( - key: ValueKey(detectorKey), - onVisibilityChanged: hasVideo - ? (info) => - _onGridCardVisibilityChanged( - index, info) - : (_) {}, - child: RepaintBoundary( - child: VideoCard( - key: ValueKey(detectorKey), - imageUrl: coverSpecs.imageUrl, - cover: coverSpecs.cover, - videoUrl: hasVideo ? videoUrl : null, - credits: '50', - showCreditsBadge: false, - showBottomGenerateButton: false, - isActive: widget.isActive && - hasVideo && - _visibleVideoIndices - .contains(index) && - !_userPausedVideoIndices - .contains(index), - onPlayRequested: () => setState(() => - _userPausedVideoIndices - .remove(index)), - onStopRequested: () => setState(() => - _userPausedVideoIndices - .add(index)), - onGenerateSimilar: openResult, + return VisibilityDetector( + key: ValueKey(detectorKey), + onVisibilityChanged: hasVideo + ? (info) => + _onGridCardVisibilityChanged( + index, info) + : (_) {}, + child: RepaintBoundary( + child: VideoCard( + key: ValueKey(detectorKey), + imageUrl: coverSpecs.imageUrl, + cover: coverSpecs.cover, + videoUrl: hasVideo + ? videoUrl + : null, + credits: '50', + showCreditsBadge: 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(() => + _userPausedVideoIndices + .remove(index)), + onStopRequested: () => + setState(() => + _userPausedVideoIndices + .add(index)), + onGenerateSimilar: () => + _onGalleryMediaTap(media), + ), + ), + ); + }, + childCount: _gridItems.length + + (_loadingMore ? 1 : 0), ), ), - ); - }, + ), + ], ), ), ), diff --git a/lib/features/gallery/models/gallery_task_item.dart b/lib/features/gallery/models/gallery_task_item.dart index 163ee4b..057876b 100644 --- a/lib/features/gallery/models/gallery_task_item.dart +++ b/lib/features/gallery/models/gallery_task_item.dart @@ -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? ?? []; final items = []; // 只取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) { 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)); diff --git a/lib/features/home/widgets/video_card.dart b/lib/features/home/widgets/video_card.dart index 9f2f465..acf1689 100644 --- a/lib/features/home/widgets/video_card.dart +++ b/lib/features/home/widgets/video_card.dart @@ -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 createState() => _VideoCardState(); @@ -369,6 +380,66 @@ class _VideoCardState extends State { ), ), ), + 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 { ), ), ), + 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, diff --git a/pubspec.yaml b/pubspec.yaml index f91e4bb..45246c5 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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'