新增:视频生成接口对接
This commit is contained in:
parent
e47e0800e5
commit
9ced4f24e7
@ -60,6 +60,11 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.google.android.gms:play-services-ads-identifier:18.1.0'
|
||||
implementation 'com.android.installreferrer:installreferrer:2.2'
|
||||
}
|
||||
|
||||
flutter {
|
||||
source '../..'
|
||||
}
|
||||
|
||||
10
docs/generate_video.md
Normal file
10
docs/generate_video.md
Normal file
@ -0,0 +1,10 @@
|
||||
# UI 开发流程
|
||||
当点击中间upload an image as the base for generation 区域的时候,调用手机相册功能选择一张图片,一次只允许一张。用户选完图片后图片显示在 upload an image as the base for generation区域,并隐藏提示信息。
|
||||
当点击Generate Video的时候如果用户没有选择图片择提示用户选择图片,如果已经选择了图片则开始按下面的步骤调用接口
|
||||
# 接口调用
|
||||
## 第一步
|
||||
通过/v1/image/upload-presigned-url接口获取文件上传地址,通常是uploadUrl1或者uploadUrl2,以及对应的filePath1和filePath2用于生成视频接口的入参
|
||||
## 第二步
|
||||
通过上一个接口获取的upload url 用put协议将图片上传到对应的地址,注意:图片上传不需要经过代理 并且需要将requiredHeaders的参数全部设置到请求头中
|
||||
## 第三步
|
||||
调用/v1/image/create-task接口并传入filePath1或者filePath2传入到srcImg1Url或者srcImg2Url
|
||||
@ -6,12 +6,24 @@
|
||||
|
||||
#import "GeneratedPluginRegistrant.h"
|
||||
|
||||
#if __has_include(<adjust_sdk/AdjustSdk.h>)
|
||||
#import <adjust_sdk/AdjustSdk.h>
|
||||
#else
|
||||
@import adjust_sdk;
|
||||
#endif
|
||||
|
||||
#if __has_include(<device_info_plus/FPPDeviceInfoPlusPlugin.h>)
|
||||
#import <device_info_plus/FPPDeviceInfoPlusPlugin.h>
|
||||
#else
|
||||
@import device_info_plus;
|
||||
#endif
|
||||
|
||||
#if __has_include(<image_picker_ios/FLTImagePickerPlugin.h>)
|
||||
#import <image_picker_ios/FLTImagePickerPlugin.h>
|
||||
#else
|
||||
@import image_picker_ios;
|
||||
#endif
|
||||
|
||||
#if __has_include(<sqflite_darwin/SqflitePlugin.h>)
|
||||
#import <sqflite_darwin/SqflitePlugin.h>
|
||||
#else
|
||||
@ -27,7 +39,9 @@
|
||||
@implementation GeneratedPluginRegistrant
|
||||
|
||||
+ (void)registerWithRegistry:(NSObject<FlutterPluginRegistry>*)registry {
|
||||
[AdjustSdk registerWithRegistrar:[registry registrarForPlugin:@"AdjustSdk"]];
|
||||
[FPPDeviceInfoPlusPlugin registerWithRegistrar:[registry registrarForPlugin:@"FPPDeviceInfoPlusPlugin"]];
|
||||
[FLTImagePickerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FLTImagePickerPlugin"]];
|
||||
[SqflitePlugin registerWithRegistrar:[registry registrarForPlugin:@"SqflitePlugin"]];
|
||||
[FVPVideoPlayerPlugin registerWithRegistrar:[registry registrarForPlugin:@"FVPVideoPlayerPlugin"]];
|
||||
}
|
||||
|
||||
@ -50,7 +50,10 @@ class _AppState extends State<App> {
|
||||
final task = ModalRoute.of(ctx)?.settings.arguments as TaskItem?;
|
||||
return GenerateVideoScreen(task: task);
|
||||
},
|
||||
'/progress': (_) => const GenerateProgressScreen(),
|
||||
'/progress': (ctx) {
|
||||
final taskId = ModalRoute.of(ctx)?.settings.arguments;
|
||||
return GenerateProgressScreen(taskId: taskId);
|
||||
},
|
||||
'/result': (_) => const GenerationResultScreen(),
|
||||
},
|
||||
),
|
||||
|
||||
@ -127,6 +127,54 @@ abstract final class ImageApi {
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取预签名上传 URL(图片上传不经过代理,直接 PUT 到返回的 URL)
|
||||
static Future<ApiResponse> getUploadPresignedUrl({
|
||||
required String fileName1,
|
||||
String? fileName2,
|
||||
required String contentType,
|
||||
required int expectedSize,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/upload-presigned-url',
|
||||
method: 'POST',
|
||||
body: {
|
||||
'gateway': fileName1,
|
||||
'action': fileName2 ?? '',
|
||||
'pauldron': contentType,
|
||||
'stronghold': expectedSize,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 创建生图/视频任务
|
||||
static Future<ApiResponse> createTask({
|
||||
required String asset,
|
||||
String? guild,
|
||||
String? commission,
|
||||
String? ledger,
|
||||
String? cipher,
|
||||
String? heatmap,
|
||||
String? congregation,
|
||||
bool allowance = false,
|
||||
String? ext,
|
||||
}) async {
|
||||
return _client.request(
|
||||
path: '/v1/image/create-task',
|
||||
method: 'POST',
|
||||
queryParams: {'asset': asset},
|
||||
body: {
|
||||
if (guild != null) 'guild': guild,
|
||||
if (commission != null) 'commission': commission,
|
||||
if (ledger != null) 'ledger': ledger,
|
||||
if (cipher != null) 'cipher': cipher,
|
||||
if (heatmap != null) 'heatmap': heatmap,
|
||||
if (congregation != null) 'congregation': congregation,
|
||||
if (ext != null) 'nexus': ext,
|
||||
'allowance': allowance,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// 获取积分页面信息
|
||||
static Future<ApiResponse> getCreditsPageInfo({
|
||||
required String sentinel,
|
||||
|
||||
@ -8,6 +8,7 @@ import 'package:flutter/foundation.dart';
|
||||
import '../api/api_client.dart';
|
||||
import '../api/proxy_client.dart';
|
||||
import '../api/services/user_api.dart';
|
||||
import '../referrer/referrer_service.dart';
|
||||
import '../user/user_state.dart';
|
||||
|
||||
/// 认证服务:APP 启动时执行快速登录
|
||||
@ -69,6 +70,11 @@ class AuthService {
|
||||
final sign = _computeSign(deviceId);
|
||||
_log('init: sign=$sign');
|
||||
|
||||
final crest = await ReferrerService.getReferrer();
|
||||
if (crest != null && crest.isNotEmpty) {
|
||||
_log('init: crest(referrer)=$crest');
|
||||
}
|
||||
|
||||
ApiResponse? res;
|
||||
for (var i = 0; i < maxRetries; i++) {
|
||||
if (i > 0) {
|
||||
@ -79,7 +85,8 @@ class AuthService {
|
||||
res = await UserApi.fastLogin(
|
||||
origin: deviceId,
|
||||
resolution: sign,
|
||||
digest: '',
|
||||
digest: crest ?? '',
|
||||
crest: crest,
|
||||
);
|
||||
break;
|
||||
} catch (e) {
|
||||
@ -106,6 +113,11 @@ class AuthService {
|
||||
UserState.setCredits(credits);
|
||||
_log('init: 已同步积分 $credits');
|
||||
}
|
||||
final uid = data?['asset'] as String?;
|
||||
if (uid != null && uid.isNotEmpty) {
|
||||
UserState.setUserId(uid);
|
||||
_log('init: 已设置 userId');
|
||||
}
|
||||
} else {
|
||||
_log('init: 登录失败');
|
||||
}
|
||||
|
||||
38
lib/core/referrer/referrer_service.dart
Normal file
38
lib/core/referrer/referrer_service.dart
Normal file
@ -0,0 +1,38 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:play_install_referrer/play_install_referrer.dart';
|
||||
|
||||
/// 安装来源 referrer 服务(用于 ch/crest 渠道参数)
|
||||
class ReferrerService {
|
||||
ReferrerService._();
|
||||
|
||||
static String? _cachedReferrer;
|
||||
static final Completer<String?> _completer = Completer<String?>();
|
||||
|
||||
/// 获取 referrer,Android 使用 Google Play Install Referrer,iOS 返回空
|
||||
static Future<String?> getReferrer() async {
|
||||
if (_cachedReferrer != null) return _cachedReferrer;
|
||||
if (_completer.isCompleted) return _completer.future;
|
||||
|
||||
if (defaultTargetPlatform != TargetPlatform.android) {
|
||||
_cachedReferrer = '';
|
||||
if (!_completer.isCompleted) _completer.complete('');
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
final details = await PlayInstallReferrer.installReferrer;
|
||||
_cachedReferrer = details.installReferrer ?? '';
|
||||
} catch (_) {
|
||||
_cachedReferrer = '';
|
||||
}
|
||||
if (!_completer.isCompleted) _completer.complete(_cachedReferrer);
|
||||
return _cachedReferrer;
|
||||
}
|
||||
|
||||
/// 初始化并缓存 referrer(建议在 app 启动时调用)
|
||||
static Future<void> init() async {
|
||||
await getReferrer();
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,16 @@ class UserState {
|
||||
UserState._();
|
||||
|
||||
static final ValueNotifier<int?> credits = ValueNotifier<int?>(null);
|
||||
static final ValueNotifier<String?> userId = ValueNotifier<String?>(null);
|
||||
|
||||
static void setCredits(int? value) {
|
||||
credits.value = value;
|
||||
}
|
||||
|
||||
static void setUserId(String? value) {
|
||||
userId.value = value;
|
||||
}
|
||||
|
||||
static String formatCredits(int? value) {
|
||||
if (value == null) return '--';
|
||||
return value.toString().replaceAllMapped(
|
||||
|
||||
@ -1,20 +1,91 @@
|
||||
import 'dart:async';
|
||||
|
||||
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';
|
||||
|
||||
/// Generate Video Progress screen - matches Pencil qGs6n
|
||||
class GenerateProgressScreen extends StatefulWidget {
|
||||
const GenerateProgressScreen({super.key});
|
||||
const GenerateProgressScreen({super.key, this.taskId});
|
||||
|
||||
final dynamic taskId;
|
||||
|
||||
@override
|
||||
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
||||
}
|
||||
|
||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
double _progress = 0.45;
|
||||
double _progress = 0;
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.taskId != null) {
|
||||
_startPolling();
|
||||
} else {
|
||||
_progress = 0.45;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pollTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _startPolling() async {
|
||||
await AuthService.loginComplete;
|
||||
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _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 progress = (data['dice'] as num?)?.toInt() ?? 0;
|
||||
final state = (data['listing'] as num?)?.toInt();
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_progress = progress / 100.0;
|
||||
});
|
||||
|
||||
if (progress >= 100 || state == 2) {
|
||||
_pollTimer?.cancel();
|
||||
Navigator.of(context).pushReplacementNamed('/result', arguments: widget.taskId);
|
||||
} else if (state == 3) {
|
||||
_pollTimer?.cancel();
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Generation failed'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -133,4 +204,3 @@ class _ProgressSection extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,15 +1,22 @@
|
||||
import 'dart:developer' as developer;
|
||||
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_lucide/flutter_lucide.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../core/theme/app_spacing.dart';
|
||||
import '../../core/theme/app_typography.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../features/home/models/task_item.dart';
|
||||
import '../../shared/widgets/top_nav_bar.dart';
|
||||
|
||||
import '../../core/api/services/image_api.dart';
|
||||
|
||||
/// Generate Video screen - matches Pencil mmLB5
|
||||
class GenerateVideoScreen extends StatefulWidget {
|
||||
const GenerateVideoScreen({super.key, this.task});
|
||||
@ -20,7 +27,27 @@ class GenerateVideoScreen extends StatefulWidget {
|
||||
State<GenerateVideoScreen> createState() => _GenerateVideoScreenState();
|
||||
}
|
||||
|
||||
enum _Resolution { p480, p720 }
|
||||
|
||||
class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
File? _selectedImage;
|
||||
_Resolution _selectedResolution = _Resolution.p480;
|
||||
bool _isGenerating = false;
|
||||
|
||||
int get _currentCredits {
|
||||
final task = widget.task;
|
||||
if (task == null) return 50;
|
||||
switch (_selectedResolution) {
|
||||
case _Resolution.p480:
|
||||
return task.credits480p ?? 50;
|
||||
case _Resolution.p720:
|
||||
return task.credits720p ?? 50;
|
||||
}
|
||||
}
|
||||
|
||||
String get _heatmap =>
|
||||
_selectedResolution == _Resolution.p480 ? '480p' : '720p';
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@ -33,6 +60,164 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _showImageSourcePicker() async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (context) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.image),
|
||||
title: const Text('Choose from gallery'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(LucideIcons.camera),
|
||||
title: const Text('Take photo'),
|
||||
onTap: () => Navigator.pop(context, ImageSource.camera),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source != null && mounted) {
|
||||
_pickImage(source);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() {
|
||||
_selectedImage = File(picked.path);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onGenerate() async {
|
||||
if (_selectedImage == null) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Please select an image first'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() => _isGenerating = true);
|
||||
|
||||
try {
|
||||
await AuthService.loginComplete;
|
||||
|
||||
final file = _selectedImage!;
|
||||
final size = await file.length();
|
||||
final ext = file.path.split('.').last.toLowerCase();
|
||||
final contentType = ext == 'png'
|
||||
? 'image/png'
|
||||
: ext == 'gif'
|
||||
? 'image/gif'
|
||||
: 'image/jpeg';
|
||||
final fileName = 'img_${DateTime.now().millisecondsSinceEpoch}.$ext';
|
||||
|
||||
final presignedRes = await ImageApi.getUploadPresignedUrl(
|
||||
fileName1: fileName,
|
||||
contentType: contentType,
|
||||
expectedSize: size,
|
||||
);
|
||||
|
||||
if (!presignedRes.isSuccess || presignedRes.data == null) {
|
||||
throw Exception(presignedRes.msg.isNotEmpty
|
||||
? presignedRes.msg
|
||||
: 'Failed to get upload URL');
|
||||
}
|
||||
|
||||
final data = presignedRes.data as Map<String, dynamic>;
|
||||
final uploadUrl =
|
||||
data['bolster'] as String? ?? data['expound'] as String?;
|
||||
final filePath = data['recruit'] as String? ?? data['train'] as String?;
|
||||
final requiredHeaders = data['remap'] as Map<String, dynamic>?;
|
||||
|
||||
if (uploadUrl == null ||
|
||||
uploadUrl.isEmpty ||
|
||||
filePath == null ||
|
||||
filePath.isEmpty) {
|
||||
throw Exception('Invalid presigned URL response');
|
||||
}
|
||||
|
||||
final headers = <String, String>{};
|
||||
if (requiredHeaders != null) {
|
||||
for (final e in requiredHeaders.entries) {
|
||||
headers[e.key] = e.value?.toString() ?? '';
|
||||
}
|
||||
}
|
||||
if (!headers.containsKey('Content-Type')) {
|
||||
headers['Content-Type'] = contentType;
|
||||
}
|
||||
|
||||
final bytes = await file.readAsBytes();
|
||||
final uploadResponse = await http.put(
|
||||
Uri.parse(uploadUrl),
|
||||
headers: headers,
|
||||
body: bytes,
|
||||
);
|
||||
|
||||
if (uploadResponse.statusCode < 200 || uploadResponse.statusCode >= 300) {
|
||||
throw Exception('Upload failed: ${uploadResponse.statusCode}');
|
||||
}
|
||||
|
||||
final userId = UserState.userId.value;
|
||||
if (userId == null || userId.isEmpty) {
|
||||
throw Exception('User not logged in');
|
||||
}
|
||||
|
||||
final createRes = await ImageApi.createTask(
|
||||
asset: userId,
|
||||
guild: filePath,
|
||||
allowance: false,
|
||||
cipher: widget.task?.taskType ?? '',
|
||||
congregation: widget.task?.templateName ?? '',
|
||||
heatmap: _heatmap,
|
||||
ext: widget.task?.ext,
|
||||
);
|
||||
|
||||
if (!createRes.isSuccess) {
|
||||
throw Exception(
|
||||
createRes.msg.isNotEmpty ? createRes.msg : 'Failed to create task');
|
||||
}
|
||||
|
||||
final taskData = createRes.data as Map<String, dynamic>?;
|
||||
final taskId = taskData?['tree'];
|
||||
|
||||
if (!mounted) return;
|
||||
Navigator.of(context)
|
||||
.pushReplacementNamed('/progress', arguments: taskId);
|
||||
} catch (e, st) {
|
||||
developer.log('Generate failed: $e', name: 'GenerateVideoScreen');
|
||||
debugPrint('[GenerateVideoScreen] error: $e\n$st');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Generation failed: ${e.toString().replaceAll('Exception: ', '')}'),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() => _isGenerating = false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@ -56,11 +241,20 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
|
||||
credits: UserCreditsData.of(context)?.creditsDisplay ?? '--',
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_UploadArea(onUpload: () {}),
|
||||
_UploadArea(
|
||||
selectedImage: _selectedImage,
|
||||
onUpload: _showImageSourcePicker,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_ResolutionToggle(
|
||||
selected: _selectedResolution,
|
||||
onChanged: (r) => setState(() => _selectedResolution = r),
|
||||
),
|
||||
const SizedBox(height: AppSpacing.xxl),
|
||||
_GenerateButton(
|
||||
onGenerate: () =>
|
||||
Navigator.of(context).pushReplacementNamed('/progress'),
|
||||
onGenerate: _onGenerate,
|
||||
isLoading: _isGenerating,
|
||||
credits: _currentCredits.toString(),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -125,8 +319,12 @@ class _CreditsCard extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _UploadArea extends StatelessWidget {
|
||||
const _UploadArea({required this.onUpload});
|
||||
const _UploadArea({
|
||||
required this.selectedImage,
|
||||
required this.onUpload,
|
||||
});
|
||||
|
||||
final File? selectedImage;
|
||||
final VoidCallback onUpload;
|
||||
|
||||
@override
|
||||
@ -143,93 +341,201 @@ class _UploadArea extends StatelessWidget {
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.image_plus,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
width: 280,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: selectedImage != null
|
||||
? Image.file(
|
||||
selectedImage!,
|
||||
fit: BoxFit.cover,
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
)
|
||||
: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.image_plus,
|
||||
size: 48,
|
||||
color: AppColors.textMuted,
|
||||
),
|
||||
const SizedBox(height: AppSpacing.lg),
|
||||
SizedBox(
|
||||
width: 280,
|
||||
child: Text(
|
||||
'Please upload an image as the base for generation',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ResolutionToggle extends StatelessWidget {
|
||||
const _ResolutionToggle({
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
final _Resolution selected;
|
||||
final ValueChanged<_Resolution> onChanged;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(_Resolution.p480),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.primary
|
||||
: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.primary.withValues(alpha: 0.5)
|
||||
: AppColors.border,
|
||||
width: selected == _Resolution.p480 ? 1 : 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'Please upload an image as the base for generation',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.textSecondary,
|
||||
'480P',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: selected == _Resolution.p480
|
||||
? AppColors.surface
|
||||
: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(_Resolution.p720),
|
||||
child: Container(
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.primary
|
||||
: AppColors.surfaceAlt,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.primary.withValues(alpha: 0.5)
|
||||
: AppColors.border,
|
||||
width: selected == _Resolution.p720 ? 1 : 2,
|
||||
),
|
||||
),
|
||||
alignment: Alignment.center,
|
||||
child: Text(
|
||||
'720P',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: selected == _Resolution.p720
|
||||
? AppColors.surface
|
||||
: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GenerateButton extends StatelessWidget {
|
||||
const _GenerateButton({required this.onGenerate});
|
||||
const _GenerateButton({
|
||||
required this.onGenerate,
|
||||
this.isLoading = false,
|
||||
this.credits = '50',
|
||||
});
|
||||
|
||||
final VoidCallback onGenerate;
|
||||
final bool isLoading;
|
||||
final String credits;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onGenerate,
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Generate Video',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.surface,
|
||||
onTap: isLoading ? null : onGenerate,
|
||||
child: Opacity(
|
||||
opacity: isLoading ? 0.6 : 1,
|
||||
child: Container(
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primaryShadow.withValues(alpha: 0.25),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 16,
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
if (isLoading)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
'50',
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'Generate Video',
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: AppColors.surface,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (!isLoading) ...[
|
||||
const SizedBox(width: AppSpacing.md),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: AppSpacing.xs,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.surface.withValues(alpha: 0.2),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
LucideIcons.sparkles,
|
||||
size: 16,
|
||||
color: AppColors.surface,
|
||||
),
|
||||
const SizedBox(width: AppSpacing.xs),
|
||||
Text(
|
||||
credits,
|
||||
style: AppTypography.bodyRegular.copyWith(
|
||||
color: AppColors.surface,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -133,9 +133,13 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
itemCount: _tasks.length,
|
||||
itemBuilder: (context, index) {
|
||||
final task = _tasks[index];
|
||||
final credits = task.credits480p != null
|
||||
? task.credits480p.toString()
|
||||
: '50';
|
||||
return VideoCard(
|
||||
imageUrl: task.previewImageUrl ?? _placeholderImage,
|
||||
videoUrl: task.previewVideoUrl,
|
||||
credits: credits,
|
||||
isActive: _activeCardIndex == index,
|
||||
onPlayRequested: () =>
|
||||
setState(() => _activeCardIndex = index),
|
||||
|
||||
@ -8,6 +8,9 @@ class TaskItem {
|
||||
this.imageCount = 0,
|
||||
this.taskType,
|
||||
this.needopt = false,
|
||||
this.ext,
|
||||
this.credits480p,
|
||||
this.credits720p,
|
||||
});
|
||||
|
||||
final String templateName;
|
||||
@ -17,17 +20,36 @@ class TaskItem {
|
||||
final int imageCount;
|
||||
final String? taskType;
|
||||
final bool needopt;
|
||||
final String? ext;
|
||||
/// 480P 积分,来自 reverify.greaves
|
||||
final int? credits480p;
|
||||
/// 720P 积分,来自 preprocess.greaves
|
||||
final int? credits720p;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'TaskItem(templateName: $templateName, title: $title, previewImageUrl: $previewImageUrl, '
|
||||
'previewVideoUrl: $previewVideoUrl, imageCount: $imageCount, taskType: $taskType, needopt: $needopt)';
|
||||
'previewVideoUrl: $previewVideoUrl, imageCount: $imageCount, taskType: $taskType, needopt: $needopt, credits480p: $credits480p, credits720p: $credits720p)';
|
||||
|
||||
factory TaskItem.fromJson(Map<String, dynamic> json) {
|
||||
final extract = json['extract'] as Map<String, dynamic>?;
|
||||
final preempt = json['preempt'] as Map<String, dynamic>?;
|
||||
final reverify = json['reverify'] as Map<String, dynamic>?;
|
||||
final preprocess = json['preprocess'] as Map<String, dynamic>?;
|
||||
final imgUrl = extract?['digitize'] as String?;
|
||||
final videoUrl = preempt?['digitize'] as String?;
|
||||
final greaves480 = reverify?['greaves'];
|
||||
final greaves720 = preprocess?['greaves'];
|
||||
final credits480p = greaves480 is int
|
||||
? greaves480
|
||||
: greaves480 is num
|
||||
? greaves480.toInt()
|
||||
: null;
|
||||
final credits720p = greaves720 is int
|
||||
? greaves720
|
||||
: greaves720 is num
|
||||
? greaves720.toInt()
|
||||
: null;
|
||||
return TaskItem(
|
||||
templateName: json['congregation'] as String? ?? '',
|
||||
title: json['glossary'] as String? ?? '',
|
||||
@ -36,6 +58,9 @@ class TaskItem {
|
||||
imageCount: json['simplify'] as int? ?? 0,
|
||||
taskType: json['cipher'] as String?,
|
||||
needopt: json['allowance'] as bool? ?? false,
|
||||
ext: json['nexus'] as String? ?? '',
|
||||
credits480p: credits480p,
|
||||
credits720p: credits720p,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,12 +1,18 @@
|
||||
import 'package:adjust_sdk/adjust_config.dart';
|
||||
import 'package:adjust_sdk/adjust.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import 'app.dart';
|
||||
import 'core/auth/auth_service.dart';
|
||||
import 'core/referrer/referrer_service.dart';
|
||||
import 'core/theme/app_colors.dart';
|
||||
|
||||
void main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
_initAdjust();
|
||||
ReferrerService.init();
|
||||
SystemChrome.setSystemUIOverlayStyle(
|
||||
const SystemUiOverlayStyle(
|
||||
statusBarColor: AppColors.surface,
|
||||
@ -18,3 +24,15 @@ void main() async {
|
||||
// APP 打开时后台执行快速登录
|
||||
AuthService.init();
|
||||
}
|
||||
|
||||
void _initAdjust() {
|
||||
const appToken = '2z2mly0afgqo';
|
||||
final config = AdjustConfig(
|
||||
appToken,
|
||||
kDebugMode ? AdjustEnvironment.sandbox : AdjustEnvironment.production,
|
||||
);
|
||||
if (kDebugMode) {
|
||||
config.logLevel = AdjustLogLevel.verbose;
|
||||
}
|
||||
Adjust.initSdk(config);
|
||||
}
|
||||
|
||||
@ -9,7 +9,9 @@ environment:
|
||||
dependencies:
|
||||
flutter:
|
||||
sdk: flutter
|
||||
adjust_sdk: ^5.5.0
|
||||
cupertino_icons: ^1.0.6
|
||||
play_install_referrer: ^0.5.0
|
||||
flutter_lucide: ^1.8.2
|
||||
google_fonts: ^6.2.1
|
||||
cached_network_image: ^3.3.1
|
||||
@ -17,6 +19,7 @@ dependencies:
|
||||
device_info_plus: ^11.1.0
|
||||
encrypt: ^5.0.3
|
||||
http: ^1.2.2
|
||||
image_picker: ^1.0.7
|
||||
video_player: ^2.9.2
|
||||
|
||||
dev_dependencies:
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user