petsHero-AI/lib/features/generate_video/generate_progress_screen.dart
2026-03-15 12:02:36 +08:00

306 lines
8.9 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 'package:flutter/material.dart';
import 'package:flutter_lucide/flutter_lucide.dart';
import '../../core/api/api_config.dart';
import '../../core/auth/auth_service.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_spacing.dart';
import '../../core/theme/app_typography.dart';
import '../../core/user/user_state.dart';
import '../../shared/widgets/top_nav_bar.dart';
import '../../core/api/services/image_api.dart';
import '../../core/api/services/user_api.dart';
import '../../features/gallery/models/gallery_task_item.dart';
import '../../shared/tab_selector_scope.dart';
import '../../shared/widgets/bottom_nav_bar.dart';
/// Progress states: 1=队列中 2=处理中 3=完成 4=超时 5=错误 6=中止
/// Progress bar has 3 stages; states 36 are stage 3.
const _stateLabels = <int, String>{
1: 'Queued',
2: 'Processing',
3: 'Completed',
4: 'Timeout',
5: 'Error',
6: 'Aborted',
};
/// Stage progress: 1 -> 1/3, 2 -> 2/3, 3..6 -> 1.0
double _progressForState(int? state) {
if (state == null) return 0;
if (state == 1) return 1 / 3;
if (state == 2) return 2 / 3;
return 1.0; // 3, 4, 5, 6
}
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick).
/// curate[].reconfigure = imgUrl, reconnect(imgType): 2=视频1或其他=图片
GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) {
final curate = data['curate'] as List<dynamic>?;
if (curate == null || curate.isEmpty) return null;
final first = curate.first;
if (first is! Map<String, dynamic>) return null;
final reconfigure = first['reconfigure'] as String?;
if (reconfigure == null || reconfigure.isEmpty) return null;
final reconnect = first['reconnect'];
final imgType = reconnect is int
? reconnect
: reconnect is num
? reconnect.toInt()
: 1;
if (imgType == 2) {
return GalleryMediaItem(videoUrl: reconfigure);
}
return GalleryMediaItem(imageUrl: reconfigure);
}
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
class GenerateProgressScreen extends StatefulWidget {
const GenerateProgressScreen({super.key, this.taskId, this.imagePath});
final dynamic taskId;
final String? imagePath;
@override
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
}
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
int? _state;
Timer? _pollTimer;
double get _progress => _progressForState(_state);
void _onBack(BuildContext context) {
final completed = _state == 3;
if (!completed) {
TabSelectorScope.maybeOf(context)?.selectTab(NavTab.gallery);
Navigator.of(context).popUntil(ModalRoute.withName('/'));
} else {
Navigator.of(context).pop();
}
}
@override
void initState() {
super.initState();
if (widget.taskId != null) {
_startPolling();
} else {
_state = 2;
}
}
@override
void dispose() {
_pollTimer?.cancel();
super.dispose();
}
Future<void> _startPolling() async {
await AuthService.loginComplete;
_pollTimer = Timer.periodic(
const Duration(seconds: 1),
(_) => _fetchProgress(),
);
_fetchProgress();
}
Future<void> _fetchProgress() async {
if (widget.taskId == null) return;
try {
final res = await ImageApi.getProgress(
sentinel: ApiConfig.appId,
tree: widget.taskId.toString(),
asset: UserState.userId.value,
);
if (!res.isSuccess || res.data == null) return;
final data = res.data as Map<String, dynamic>;
final state = (data['listing'] as num?)?.toInt();
if (!mounted) return;
setState(() => _state = state);
switch (state) {
case 3: // 完成
_pollTimer?.cancel();
// 生成成功后同步更新积分
final userId = UserState.userId.value;
if (userId != null && userId.isNotEmpty) {
try {
final accountRes = await UserApi.getAccount(
sentinel: ApiConfig.appId,
asset: userId,
);
if (accountRes.isSuccess && accountRes.data != null) {
final accountData = accountRes.data as Map<String, dynamic>?;
final credits = accountData?['reveal'] as int?;
if (credits != null) {
UserState.setCredits(credits);
}
}
} catch (_) {}
}
if (!mounted) return;
final mediaItem = _mediaItemFromProgressData(data);
Navigator.of(context).pushReplacementNamed(
'/result',
arguments: mediaItem,
);
break;
case 4:
case 5:
case 6: // 超时 / 错误 / 中止
_pollTimer?.cancel();
break;
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final labelText = _stateLabels[_state] ?? '队列中';
return Scaffold(
backgroundColor: AppColors.background,
appBar: PreferredSize(
preferredSize: const Size.fromHeight(56),
child: TopNavBar(
title: 'Generating',
showBackButton: true,
onBack: () => _onBack(context),
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(AppSpacing.screenPaddingLarge),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_VideoPreview(imagePath: widget.imagePath),
const SizedBox(height: AppSpacing.xxl),
Text(
'Generating may take some time. Please wait patiently.',
textAlign: TextAlign.center,
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
const SizedBox(height: AppSpacing.xxl),
_ProgressSection(progress: _progress, label: labelText),
],
),
),
);
}
}
/// Video area rSN3T: show uploaded image with crop (BoxFit.cover)
class _VideoPreview extends StatelessWidget {
const _VideoPreview({this.imagePath});
final String? imagePath;
@override
Widget build(BuildContext context) {
final hasImage = imagePath != null &&
imagePath!.isNotEmpty &&
File(imagePath!).existsSync();
return Container(
height: 360,
decoration: BoxDecoration(
color: AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1),
),
clipBehavior: Clip.antiAlias,
child: hasImage
? Image.file(
File(imagePath!),
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
LucideIcons.film,
size: 64,
color: AppColors.textSecondary,
),
const SizedBox(height: AppSpacing.lg),
Text(
'Previewing',
style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary,
),
),
],
),
);
}
}
/// Progress bar: 3 stages. Label Eghqc shows state (队列中|处理中|完成|超时|错误|中止)
class _ProgressSection extends StatelessWidget {
const _ProgressSection({required this.progress, required this.label});
final double progress;
final String label;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
label,
style: AppTypography.bodyMedium.copyWith(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: AppSpacing.lg),
LayoutBuilder(
builder: (context, constraints) {
final fillWidth = constraints.maxWidth * progress.clamp(0.0, 1.0);
return Stack(
children: [
Container(
height: 8,
decoration: BoxDecoration(
color: AppColors.border,
borderRadius: BorderRadius.circular(4),
),
),
Positioned(
left: 0,
top: 0,
bottom: 0,
child: Container(
width: fillWidth,
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(4),
),
),
),
],
);
},
),
],
);
}
}