petsHero-AI/lib/features/home/home_screen.dart

405 lines
15 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 'package:flutter/material.dart';
import 'package:visibility_detector/visibility_detector.dart';
import '../../core/api/services/image_api.dart';
import '../../core/auth/auth_service.dart';
import '../../core/user/account_refresh.dart';
import '../../core/user/user_state.dart';
import '../../core/theme/app_spacing.dart';
import '../../shared/widgets/top_nav_bar.dart';
import 'models/category_item.dart';
import 'models/ext_config_item.dart';
import 'models/task_item.dart';
import 'widgets/home_tab_row.dart';
import 'home_playback_resume.dart';
import 'widgets/video_card.dart';
/// 固定「pets」分类 id用于展示 extConfig.items
const int kExtCategoryId = -1;
/// AI Video App home screen - tab 来自分类接口Grid 来自任务列表或 extConfig.items
class HomeScreen extends StatefulWidget {
const HomeScreen({super.key, this.isActive = true});
final bool isActive;
@override
State<HomeScreen> createState() => _HomeScreenState();
}
class _HomeScreenState extends State<HomeScreen> {
List<CategoryItem> _categories = [];
CategoryItem? _selectedCategory;
List<TaskItem> _tasks = [];
bool _categoriesLoading = true;
bool _tasksLoading = false;
/// 当前在屏幕上有足够可见比例、且带有预览视频的格子(由 [VisibilityDetector] 实测,允许多个)
Set<int> _visibleVideoIndices = {};
/// 用户在本轮可视周期内点过「停止」的索引;滑出视口后清除,再次进入可自动播放
final Set<int> _userPausedVideoIndices = {};
final ScrollController _scrollController = ScrollController();
/// [index] -> 最近一次 visibility 回调的 [VisibilityInfo.visibleFraction]
final Map<int, double> _cardVisibleFraction = {};
bool _visibilityReconcileScheduled = false;
/// IndexedStack 切回首页或 [homePlaybackResumeNonce] 递增后,让 [VisibilityDetector] 重新计算。
/// 单次 [notifyNow] 在「Modal 收起 / 子路由 pop」后首帧常过早与 My Gallery 一致采用多帧 + 短延迟。
void _scheduleVisibilityRefresh() {
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();
UserState.needShowVideoMenu.addListener(_onExtConfigChanged);
UserState.extConfigItems.addListener(_onExtConfigChanged);
AuthService.isLoginComplete.addListener(_onExtConfigChanged);
UserState.homeReloadNonce.addListener(_onHomeReloadNonce);
homePlaybackResumeNonce.addListener(_onHomePlaybackResumeSignal);
_loadCategories();
if (widget.isActive) refreshAccount();
}
@override
void dispose() {
homePlaybackResumeNonce.removeListener(_onHomePlaybackResumeSignal);
UserState.needShowVideoMenu.removeListener(_onExtConfigChanged);
UserState.extConfigItems.removeListener(_onExtConfigChanged);
AuthService.isLoginComplete.removeListener(_onExtConfigChanged);
UserState.homeReloadNonce.removeListener(_onHomeReloadNonce);
_scrollController.dispose();
super.dispose();
}
void _onHomePlaybackResumeSignal() {
if (!mounted || !widget.isActive) return;
_scheduleVisibilityRefresh();
}
void _onExtConfigChanged() {
if (mounted) setState(() {});
}
void _onHomeReloadNonce() {
if (!mounted) return;
_loadCategories();
}
static const double _videoVisibilityThreshold = 0.15;
void _onGridCardVisibilityChanged(int index, VisibilityInfo info) {
if (!mounted) return;
if (info.visibleFraction >= _videoVisibilityThreshold) {
_cardVisibleFraction[index] = info.visibleFraction;
} else {
_cardVisibleFraction.remove(index);
}
if (_visibilityReconcileScheduled) return;
_visibilityReconcileScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_visibilityReconcileScheduled = false;
if (mounted) _reconcileVisibleVideoIndicesFromDetector();
});
}
void _reconcileVisibleVideoIndicesFromDetector() {
final tasks = _displayTasks;
final next = <int>{};
for (final e in _cardVisibleFraction.entries) {
final i = e.key;
if (i < 0 || i >= tasks.length) continue;
if (e.value < _videoVisibilityThreshold) continue;
final url = tasks[i].previewVideoUrl;
if (url != null && url.isNotEmpty) {
next.add(i);
}
}
_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;
}
@override
void didUpdateWidget(covariant HomeScreen oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.isActive && !oldWidget.isActive) {
refreshAccount();
_scheduleVisibilityRefresh();
}
}
/// 仅 need_wait === true 时展示 Video 分类栏其他false/null/未解析)只显示图片列表
bool get _showVideoMenu =>
UserState.needShowVideoMenu.value == true;
List<ExtConfigItem> get _parsedExtItems {
final raw = UserState.extConfigItems.value;
if (raw == null || raw.isEmpty) return [];
return raw
.map((e) => e is Map<String, dynamic>
? ExtConfigItem.fromJson(e)
: ExtConfigItem.fromJson(Map<String, dynamic>.from(e as Map)))
.toList();
}
/// 是否处于首次加载中(分类/任务/extConfig 尚未就绪)
/// 登录未完成时不显示页面加载指示器,由登录遮罩负责
bool get _isListLoading {
if (!AuthService.isLoginComplete.value) return false;
if (_showVideoMenu) {
if (_categoriesLoading) return true;
if (_selectedCategory?.id == kExtCategoryId) {
return UserState.extConfigItems.value == null;
}
return _tasksLoading;
}
return UserState.extConfigItems.value == null;
}
/// 分类栏加载中时是否显示加载指示器(登录未完成时不显示)
bool get _showCategoriesLoading =>
AuthService.isLoginComplete.value && _categoriesLoading;
/// 当前列表need_wait false 时用 extConfig.itemstrue 且选中固定分类时用 extConfig.items否则用 _tasks
List<TaskItem> get _displayTasks {
if (!_showVideoMenu) {
return _extItemsToTaskItems(_parsedExtItems);
}
if (_selectedCategory?.id == kExtCategoryId) {
return _extItemsToTaskItems(_parsedExtItems);
}
return _tasks;
}
static List<TaskItem> _extItemsToTaskItems(List<ExtConfigItem> items) {
return items
.map((e) => TaskItem(
templateName: e.title,
title: e.title,
previewImageUrl: e.image,
// 尝试从 detail 字段中提取视频 URL如果有
previewVideoUrl: null, // 暂时保持为 null需要根据实际数据结构调整
taskType: e.title,
ext: e.detail,
credits480p: e.cost,
))
.toList();
}
Future<void> _loadCategories() async {
print('HomeScreen: _loadCategories called');
setState(() => _categoriesLoading = true);
await AuthService.loginComplete;
if (!mounted) return;
final res = await ImageApi.getCategoryList();
if (mounted) {
if (res.isSuccess && res.data is List) {
final list = (res.data as List)
.map((e) => CategoryItem.fromJson(e as Map<String, dynamic>))
.toList();
print('HomeScreen: Categories loaded: ${list.length} items');
if (UserState.needShowVideoMenu.value == true) {
list.add(const CategoryItem(id: kExtCategoryId, name: 'pets', icon: null));
print('HomeScreen: Added pets category');
}
setState(() {
_categories = list;
_selectedCategory = list.isNotEmpty ? list.first : null;
print('HomeScreen: Selected category: ${_selectedCategory?.name}');
if (_selectedCategory != null) {
if (_selectedCategory!.id == kExtCategoryId) {
_tasks = [];
_tasksLoading = false;
print('HomeScreen: Selected pets category, tasks cleared');
} else {
print('HomeScreen: Loading tasks for category: ${_selectedCategory!.id}');
_loadTasks(_selectedCategory!.id);
}
}
});
} else {
setState(() => _categories = []);
print('HomeScreen: Failed to load categories');
}
setState(() => _categoriesLoading = false);
}
}
Future<void> _loadTasks(int categoryId) async {
setState(() => _tasksLoading = true);
final res = await ImageApi.getImg2VideoTasks(insignia: categoryId);
if (mounted) {
if (res.isSuccess && res.data is List) {
final list = (res.data as List)
.map((e) => TaskItem.fromJson(e as Map<String, dynamic>))
.toList();
setState(() {
_tasks = list;
_userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
});
} else {
setState(() {
_tasks = [];
_userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
});
}
setState(() => _tasksLoading = false);
}
}
void _onTabChanged(CategoryItem c) {
setState(() => _selectedCategory = c);
if (c.id == kExtCategoryId) {
setState(() {
_tasks = [];
_tasksLoading = false;
_userPausedVideoIndices.clear();
_visibleVideoIndices = {};
_cardVisibleFraction.clear();
});
} else {
_loadTasks(c.id);
}
}
static const _placeholderImage =
'https://images.unsplash.com/photo-1763929272543-0df093e4f659?w=400';
/// 与 [GenerateVideoScreen] 默认 480P 消耗一致;不足则去充值,够才进生图页
void _openGeneratePage(TaskItem task) {
final requiredCredits = task.credits480p ?? 50;
final balance = UserState.credits.value ?? 0;
if (balance < requiredCredits) {
Navigator.of(context).pushNamed('/recharge');
return;
}
Navigator.of(context).pushNamed('/generate', arguments: task);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFFAFAFA),
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: TopNavBar(
title: 'PetsHero AI',
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
onCreditsTap: () => Navigator.of(context).pushNamed('/recharge'),
),
),
body: Column(
children: [
// 仅 need_wait == true 时展示顶部分类栏Pencil: tabRow bK6o6
if (_showVideoMenu)
Padding(
padding: const EdgeInsets.symmetric(
horizontal: AppSpacing.screenPadding,
vertical: AppSpacing.xs,
),
child: _showCategoriesLoading
? const SizedBox(
height: 40,
child: Center(child: CircularProgressIndicator()))
: HomeTabRow(
categories: _categories,
selectedId: _selectedCategory?.id ?? -1,
onTabChanged: _onTabChanged,
),
),
Expanded(
child: _isListLoading
? const Center(child: CircularProgressIndicator())
: LayoutBuilder(
builder: (context, constraints) {
final tasks = _displayTasks;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 390),
child: GridView.builder(
padding: const EdgeInsets.fromLTRB(
AppSpacing.screenPadding,
AppSpacing.xl,
AppSpacing.screenPadding,
AppSpacing.screenPaddingLarge,
),
controller: _scrollController,
gridDelegate:
const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
childAspectRatio: 165 / 248,
mainAxisSpacing: AppSpacing.xl,
crossAxisSpacing: AppSpacing.xl,
),
itemCount: tasks.length,
itemBuilder: (context, index) {
final task = tasks[index];
final credits = task.credits480p != null
? task.credits480p.toString()
: '50';
final detectorKey =
'home_card_${index}_${task.previewVideoUrl ?? ''}_${task.title}';
return VisibilityDetector(
key: ValueKey(detectorKey),
onVisibilityChanged: (info) =>
_onGridCardVisibilityChanged(index, info),
child: VideoCard(
key: ValueKey(detectorKey),
imageUrl: task.previewImageUrl ??
_placeholderImage,
videoUrl: task.previewVideoUrl,
credits: credits,
isActive:
_visibleVideoIndices.contains(index) &&
!_userPausedVideoIndices
.contains(index),
onPlayRequested: () => setState(() =>
_userPausedVideoIndices.remove(index)),
onStopRequested: () => setState(() =>
_userPausedVideoIndices.add(index)),
onGenerateSimilar: () =>
_openGeneratePage(task),
),
);
},
),
),
);
},
),
),
],
),
);
}
}