diff --git a/android/app/build.gradle b/android/app/build.gradle index 8e449b0..7af8182 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -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 '../..' } diff --git a/docs/generate_video.md b/docs/generate_video.md new file mode 100644 index 0000000..d27f537 --- /dev/null +++ b/docs/generate_video.md @@ -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 diff --git a/ios/Runner/GeneratedPluginRegistrant.m b/ios/Runner/GeneratedPluginRegistrant.m index 517026c..e50ad0e 100644 --- a/ios/Runner/GeneratedPluginRegistrant.m +++ b/ios/Runner/GeneratedPluginRegistrant.m @@ -6,12 +6,24 @@ #import "GeneratedPluginRegistrant.h" +#if __has_include() +#import +#else +@import adjust_sdk; +#endif + #if __has_include() #import #else @import device_info_plus; #endif +#if __has_include() +#import +#else +@import image_picker_ios; +#endif + #if __has_include() #import #else @@ -27,7 +39,9 @@ @implementation GeneratedPluginRegistrant + (void)registerWithRegistry:(NSObject*)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"]]; } diff --git a/lib/app.dart b/lib/app.dart index 280518d..a80e319 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -50,7 +50,10 @@ class _AppState extends State { 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(), }, ), diff --git a/lib/core/api/services/image_api.dart b/lib/core/api/services/image_api.dart index 29619c1..ba24cd8 100644 --- a/lib/core/api/services/image_api.dart +++ b/lib/core/api/services/image_api.dart @@ -127,6 +127,54 @@ abstract final class ImageApi { ); } + /// 获取预签名上传 URL(图片上传不经过代理,直接 PUT 到返回的 URL) + static Future 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 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 getCreditsPageInfo({ required String sentinel, diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index cbf8f01..956fa7e 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -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: 登录失败'); } diff --git a/lib/core/referrer/referrer_service.dart b/lib/core/referrer/referrer_service.dart new file mode 100644 index 0000000..2e7631f --- /dev/null +++ b/lib/core/referrer/referrer_service.dart @@ -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 _completer = Completer(); + + /// 获取 referrer,Android 使用 Google Play Install Referrer,iOS 返回空 + static Future 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 init() async { + await getReferrer(); + } +} diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart index 982c77f..a33b0ce 100644 --- a/lib/core/user/user_state.dart +++ b/lib/core/user/user_state.dart @@ -5,11 +5,16 @@ class UserState { UserState._(); static final ValueNotifier credits = ValueNotifier(null); + static final ValueNotifier userId = ValueNotifier(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( diff --git a/lib/features/generate_video/generate_progress_screen.dart b/lib/features/generate_video/generate_progress_screen.dart index 043cf26..507a920 100644 --- a/lib/features/generate_video/generate_progress_screen.dart +++ b/lib/features/generate_video/generate_progress_screen.dart @@ -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 createState() => _GenerateProgressScreenState(); } class _GenerateProgressScreenState extends State { - 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 _startPolling() async { + await AuthService.loginComplete; + + _pollTimer = Timer.periodic(const Duration(seconds: 2), (_) => _fetchProgress()); + _fetchProgress(); + } + + Future _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; + 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 { ); } } - diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 80781b7..3b55f74 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -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 createState() => _GenerateVideoScreenState(); } +enum _Resolution { p480, p720 } + class _GenerateVideoScreenState extends State { + 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 { }); } + Future _showImageSourcePicker() async { + final source = await showModalBottomSheet( + 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 _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 _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; + 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?; + + if (uploadUrl == null || + uploadUrl.isEmpty || + filePath == null || + filePath.isEmpty) { + throw Exception('Invalid presigned URL response'); + } + + final headers = {}; + 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?; + 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 { 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, + ), + ), + ], + ), + ), + ], + ], + ), ), ), ); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 65d85e7..1ed84b9 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -133,9 +133,13 @@ class _HomeScreenState extends State { 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), diff --git a/lib/features/home/models/task_item.dart b/lib/features/home/models/task_item.dart index ceded24..22ddd38 100644 --- a/lib/features/home/models/task_item.dart +++ b/lib/features/home/models/task_item.dart @@ -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 json) { final extract = json['extract'] as Map?; final preempt = json['preempt'] as Map?; + final reverify = json['reverify'] as Map?; + final preprocess = json['preprocess'] as Map?; 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, ); } } diff --git a/lib/main.dart b/lib/main.dart index 1b8e18a..6bb4b24 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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); +} diff --git a/pubspec.yaml b/pubspec.yaml index d8d9dbe..139226d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: