329 lines
9.4 KiB
Dart
329 lines
9.4 KiB
Dart
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=Queued 2=Processing 3=Completed 4=Timeout 5=Error 6=Aborted
|
||
/// Progress bar has 3 stages; states 3–6 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
|
||
}
|
||
|
||
int? _parseProgressTaskId(dynamic raw) {
|
||
if (raw == null) return null;
|
||
if (raw is int) return raw > 0 ? raw : null;
|
||
if (raw is num) {
|
||
final v = raw.toInt();
|
||
return v > 0 ? v : null;
|
||
}
|
||
final v = int.tryParse(raw.toString());
|
||
return (v != null && v > 0) ? v : null;
|
||
}
|
||
|
||
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick).
|
||
/// curate[].reconfigure = imgUrl, reconnect(imgType): 2=视频,1或其他=图片
|
||
GalleryMediaItem? _mediaItemFromProgressData(
|
||
Map<String, dynamic> data, {
|
||
int? taskId,
|
||
}) {
|
||
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, taskId: taskId);
|
||
}
|
||
return GalleryMediaItem(imageUrl: reconfigure, taskId: taskId);
|
||
}
|
||
|
||
/// 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;
|
||
bool _isFetching = false;
|
||
|
||
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: 5),
|
||
(_) => _fetchProgress(),
|
||
);
|
||
_fetchProgress();
|
||
}
|
||
|
||
Future<void> _fetchProgress() async {
|
||
if (widget.taskId == null) return;
|
||
if (_isFetching) return;
|
||
|
||
_isFetching = true;
|
||
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,
|
||
taskId: _parseProgressTaskId(widget.taskId),
|
||
);
|
||
Navigator.of(context).pushReplacementNamed(
|
||
'/result',
|
||
arguments: mediaItem,
|
||
);
|
||
break;
|
||
case 4:
|
||
case 5:
|
||
case 6: // 超时 / 错误 / 中止
|
||
_pollTimer?.cancel();
|
||
break;
|
||
}
|
||
} catch (_) {}
|
||
finally {
|
||
_isFetching = false;
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final labelText = _stateLabels[_state] ?? 'Queued';
|
||
|
||
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 shows state (Queued|Processing|Completed|Timeout|Error|Aborted)
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|