petsHero-AI/lib/features/gallery/gallery_screen.dart

604 lines
24 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: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';
import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/util/device_memory_profile.dart';
import '../../shared/widgets/top_nav_bar.dart';
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});
final bool isActive;
@override
State<GalleryScreen> createState() => _GalleryScreenState();
}
class _GalleryScreenState extends State<GalleryScreen> {
List<GalleryTaskItem> _tasks = [];
bool _loading = true;
bool _loadingMore = false;
String? _error;
int _currentPage = 1;
bool _hasNext = false;
bool _hasLoadedOnce = false;
final ScrollController _scrollController = ScrollController();
static const int _pageSize = 20;
/// 略低于首页:网格边缘格子可见但比例偏低时也应计入「在屏」
static const double _videoVisibilityThreshold = 0.08;
/// 可见格子按曝光比例排序,同时最多自动播放 [deviceGridMaxConcurrentVideos] 路;滑出后 [VideoCard] 释放解码器
Set<int> _visibleVideoIndices = {};
final Set<int> _userPausedVideoIndices = {};
final Map<int, double> _cardVisibleFraction = {};
Timer? _visibilityDebounce;
Timer? _remainingLabelTicker;
/// taskId(tree) -> 本地上传封面路径(接口无封面时使用)
Map<int, String> _localCoverPaths = {};
/// [IndexedStack] 非当前 Tab 时子树可见性常为 0首次切到本页时若仅打一次 [notifyNow]
/// 可能在 Grid 尚未挂载(仍在 loading或首帧未稳定时执行导致预览不自动播放。
/// 多帧 + 短延迟与数据就绪后再通知一次,与首页 [HomeScreen._scheduleVisibilityRefresh] 思路一致。
void _scheduleGalleryVisibilityRefresh() {
if (!mounted || !widget.isActive) return;
void notify() {
if (!mounted || !widget.isActive) return;
VisibilityDetectorController.instance.notifyNow();
}
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
WidgetsBinding.instance.addPostFrameCallback((_) {
notify();
});
});
});
Future<void>.delayed(const Duration(milliseconds: 80), notify);
Future<void>.delayed(const Duration(milliseconds: 220), notify);
}
@override
void initState() {
super.initState();
_scrollController.addListener(_onScroll);
_remainingLabelTicker =
Timer.periodic(const Duration(minutes: 1), (_) {
if (mounted) setState(() {});
});
if (widget.isActive) _loadTasks(refresh: true);
}
@override
void didUpdateWidget(covariant GalleryScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
_scheduleGalleryVisibilityRefresh();
if (!_hasLoadedOnce) {
_loadTasks(refresh: true);
}
}
}
@override
void dispose() {
_remainingLabelTicker?.cancel();
_visibilityDebounce?.cancel();
_scrollController.dispose();
super.dispose();
}
void _onScroll() {
if (_loadingMore || !_hasNext || _tasks.isEmpty) return;
if (!_scrollController.hasClients) return;
final pos = _scrollController.position;
if (pos.pixels >= pos.maxScrollExtent - 200) {
_loadTasks(refresh: false);
}
}
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
if (!mounted) return;
if (info.visibleFraction >= _videoVisibilityThreshold) {
_cardVisibleFraction[index] = info.visibleFraction;
} else {
_cardVisibleFraction.remove(index);
}
// 防抖:滚动时可见性回调极频繁,立刻 setState 会让 VideoCard / FutureBuilder 反复重建导致闪屏
_visibilityDebounce?.cancel();
_visibilityDebounce = Timer(const Duration(milliseconds: 120), () {
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
});
}
void _reconcileVisibleVideoIndicesFromDetector() {
final items = _gridItems;
final scored = <MapEntry<int, double>>[];
for (final e in _cardVisibleFraction.entries) {
final i = e.key;
if (i < 0 || i >= items.length) continue;
if (e.value < _videoVisibilityThreshold) continue;
final url = items[i].videoUrl;
if (url != null && url.isNotEmpty) {
scored.add(MapEntry(i, e.value));
}
}
scored.sort((a, b) => b.value.compareTo(a.value));
final next = scored
.take(deviceGridMaxConcurrentVideos)
.map((e) => e.key)
.toSet();
_userPausedVideoIndices.removeWhere((i) => !next.contains(i));
if (!_setsEqual(next, _visibleVideoIndices)) {
setState(() => _visibleVideoIndices = next);
}
}
bool _setsEqual(Set<int> a, Set<int> b) {
if (a.length != b.length) return false;
for (final e in a) {
if (!b.contains(e)) return false;
}
return true;
}
static const String _defaultGalleryCoverAsset = 'assets/images/logo.png';
/// 网络封面优先;无则本地上传图(按 tree再无则默认占位图
({String imageUrl, Widget? cover}) _galleryCardCover(GalleryMediaItem media) {
final net = media.imageUrl?.trim() ?? '';
if (net.isNotEmpty) {
return (imageUrl: net, cover: null);
}
final tid = media.taskId;
if (tid != null && tid > 0) {
final path = _localCoverPaths[tid];
if (path != null && path.isNotEmpty && File(path).existsSync()) {
return (
imageUrl: '',
cover: Image.file(File(path), fit: BoxFit.cover),
);
}
}
return (
imageUrl: '',
cover: Image.asset(
_defaultGalleryCoverAsset,
fit: BoxFit.cover,
),
);
}
Future<void> _refreshLocalCoverPaths() async {
if (!mounted) return;
final ids = _tasks.map((t) => t.taskId).where((id) => id > 0).toSet();
final paths = await GalleryUploadCoverStore.existingPathsForTaskIds(ids);
if (!mounted) return;
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(() {
_loading = true;
_error = null;
_currentPage = 1;
});
} else {
if (_loadingMore || !_hasNext) return;
setState(() => _loadingMore = true);
}
try {
await AuthService.loginComplete;
final page = refresh ? 1 : _currentPage;
final res = await ImageApi.getMyTasks(
sentinel: ApiConfig.appId,
trophy: page.toString(),
heatmap: _pageSize.toString(),
);
if (!mounted) return;
if (res.isSuccess && res.data != null) {
final data = res.data as Map<String, dynamic>?;
final intensify = data?['intensify'] as List<dynamic>? ?? [];
final list = intensify
.whereType<Map<String, dynamic>>()
.map((e) => GalleryTaskItem.fromJson(e))
.toList();
final hasNext = data?['manifest'] as bool? ?? false;
setState(() {
if (refresh) {
_tasks = list;
_visibleVideoIndices = {};
_userPausedVideoIndices.clear();
_cardVisibleFraction.clear();
_localCoverPaths = {};
} else {
_tasks = [..._tasks, ...list];
}
_currentPage = page + 1;
_hasNext = hasNext;
_loading = false;
_loadingMore = false;
_hasLoadedOnce = true;
});
await _refreshLocalCoverPaths();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) _scheduleGalleryVisibilityRefresh();
});
} else {
setState(() {
if (refresh) _tasks = [];
_loading = false;
_loadingMore = false;
_error = res.msg.isNotEmpty ? res.msg : 'Failed to load';
});
}
} catch (e) {
if (mounted) {
setState(() {
if (refresh) _tasks = [];
_loading = false;
_loadingMore = false;
_error = e.toString();
});
}
}
}
List<GalleryMediaItem> get _gridItems {
final items = <GalleryMediaItem>[];
for (final task in _tasks) {
items.addAll(task.mediaItems);
}
return items;
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: const PreferredSize(
preferredSize: Size.fromHeight(56),
child: TopNavBar(
title: 'Gallery',
),
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
_error!,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.textSecondary),
),
const SizedBox(height: AppSpacing.lg),
TextButton(
onPressed: _loadTasks,
child: const Text('Retry'),
),
],
),
)
: LayoutBuilder(
builder: (context, constraints) {
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 390),
child: RefreshIndicator(
onRefresh: () => _loadTasks(refresh: true),
child: _gridItems.isEmpty && !_loading
? 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: max(0.0, constraints.maxHeight - 220),
child: const Center(
child: Text(
'No images yet',
style: TextStyle(
color: AppColors.textSecondary,
),
),
),
),
),
],
)
: 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 = !deviceGridStaticPreviewOnly &&
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,
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),
),
),
),
],
),
),
),
);
},
),
);
}
}