From ea14c36d63d4bc54b6b0cd646047c149765bf119 Mon Sep 17 00:00:00 2001 From: ivan Date: Fri, 13 Mar 2026 22:04:57 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=EF=BC=9A=E5=9B=BE=E7=94=9F?= =?UTF-8?q?=E5=9B=BE=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api_flow_summary.md | 2 +- docs/googlepay.md | 2 +- docs/home.md | 2 +- docs/payment_flow.md | 12 +- docs/user_login.md | 8 +- lib/core/auth/auth_service.dart | 4 +- lib/core/user/user_state.dart | 2 +- lib/features/gallery/gallery_screen.dart | 26 +-- .../gallery/models/gallery_task_item.dart | 26 +-- .../generate_progress_screen.dart | 40 +++-- .../generate_video/generate_video_screen.dart | 151 ++++++++++++++++-- .../generation_result_screen.dart | 4 +- 12 files changed, 201 insertions(+), 78 deletions(-) diff --git a/docs/api_flow_summary.md b/docs/api_flow_summary.md index 97f1514..116f326 100644 --- a/docs/api_flow_summary.md +++ b/docs/api_flow_summary.md @@ -70,7 +70,7 @@ | 1 | `/v1/user/fast_login` | POST | 设备快速登录;body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 | | 2 | (保存 token、用户信息到 UserState;首次登录打点 register) | — | — | | 3 | `/v1/user/referrer` | POST | 归因上报;query: sentinel, asset(uid), portal;body: digest, origin。 | -| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 enable_third_party_payment 等。 | +| 4 | `/v1/user/common_info` | GET | 获取用户通用信息;query: sentinel, asset(uid)。解析 data 写入 UserState,并解析 surge 中 lucky 等。 | **调用处**:`lib/core/auth/auth_service.dart`(init,登录成功后顺序执行)。 diff --git a/docs/googlepay.md b/docs/googlepay.md index db96950..c639cb1 100644 --- a/docs/googlepay.md +++ b/docs/googlepay.md @@ -6,7 +6,7 @@ ## 1. 流程总览 -- **第三方支付开启**(`enable_third_party_payment === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`。 +- **第三方支付开启**(`lucky === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`。 - **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。 ``` diff --git a/docs/home.md b/docs/home.md index 19ebea7..e38f317 100644 --- a/docs/home.md +++ b/docs/home.md @@ -20,7 +20,7 @@ ## 数据流简述 1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`: - - 写入 `enable_third_party_payment` 等; + - 写入 `lucky` 等; - 解析 `need_wait`、`items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。 2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu`、`UserState.extConfigItems`,据此决定: - 是否渲染顶部分类栏; diff --git a/docs/payment_flow.md b/docs/payment_flow.md index 1607365..d184267 100644 --- a/docs/payment_flow.md +++ b/docs/payment_flow.md @@ -9,7 +9,7 @@ ``` 用户点击 Buy │ - ├─ enable_third_party_payment === true 且已登录 + ├─ lucky === true 且已登录 │ │ │ ├─ getPaymentMethods(activityId) │ ├─ 弹窗选择支付方式(_PaymentMethodDialog) @@ -23,7 +23,7 @@ │ └─ 否则(其他支付方式) │ └─ 打开 createPayment 返回的 payUrl(convert)在外部浏览器完成支付 │ - └─ enable_third_party_payment !== true 或未登录 + └─ lucky !== true 或未登录 └─ 仅 Android:直接调起 Google Play 内购(productId = item.code),无 createPayment ``` @@ -31,10 +31,10 @@ ## 2. 支付分支依据 -- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **enable_third_party_payment**。 +- **数据来源**:`/v1/user/common_info` 响应中的 **surge**(JSON 字符串),解析得到 **lucky**。 - **客户端状态**:`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。 - **分支**: - - **第三方支付**:`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。 + - **第三方支付**:`lucky == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。 - **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。 --- @@ -58,7 +58,7 @@ --- -## 4. 第三方支付流程(enable_third_party_payment === true) +## 4. 第三方支付流程(lucky === true) ### 4.1 步骤顺序 @@ -87,7 +87,7 @@ --- -## 5. 直接谷歌支付(enable_third_party_payment !== true) +## 5. 直接谷歌支付(lucky !== true) - 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment;凭据可用于后续服务端回调。 - 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 diff --git a/docs/user_login.md b/docs/user_login.md index 8770480..e862671 100644 --- a/docs/user_login.md +++ b/docs/user_login.md @@ -9,7 +9,7 @@ 1. **设备快速登录**:`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。 2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等;**首次登录**记录为注册日期。 3. **归因上报**:`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest)上报。 -4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `enable_third_party_payment` 等)。 +4. **获取用户通用信息**:`GET /v1/user/common_info`,拉取通用配置并保存(含 `surge` 中 `lucky` 等)。 登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken)。 @@ -93,13 +93,13 @@ - 与 fast_login 的 data 结构类似,含 **surge**(extConfig)、积分、用户信息等。 - **surge**:字符串,为 JSON;需 **先 JSON 解析再使用**。 - - 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。 + - 解析后的对象中,包含 **lucky** 等字段,用于控制第三方支付等能力。 - 其他字段(reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。 ### 客户端必做 - 调用 common_info 并将结果**保存到全局/状态**。 -- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。 +- 对 **surge** 做 **JSON decode**,得到对象后读取并保存 **lucky** 等配置。 --- @@ -117,7 +117,7 @@ APP 启动 → 成功后打日志 4. GET /v1/user/common_info(query: sentinel, asset) → 将结果保存到全局 - → 对 data.surge 做 JSON decode,保存 enable_third_party_payment 等 + → 对 data.surge 做 JSON decode,保存 lucky 等 → 之后所有请求 Header 带 knight = userToken ``` diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index db832a5..7c7fa5b 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -52,7 +52,7 @@ class AuthService { return digest.toString().toUpperCase(); } - /// 将 common_info 响应保存到全局,并解析 surge 中的 enable_third_party_payment + /// 将 common_info 响应保存到全局,并解析 surge 中的 lucky(是否开启三方支付) static void _saveCommonInfoToState(Map data) { final reveal = data['reveal'] as int?; if (reveal != null) UserState.setCredits(reveal); @@ -68,7 +68,7 @@ class AuthService { try { final surge = json.decode(surgeStr) as Map?; if (surge != null) { - final enable = surge['enable_third_party_payment'] as bool?; + final enable = surge['lucky'] as bool?; UserState.setEnableThirdPartyPayment(enable); // extConfig:need_wait = 是否展示 Video 菜单,items = 图片列表(见 docs/extConfig.md) final needWait = surge['need_wait'] as bool?; diff --git a/lib/core/user/user_state.dart b/lib/core/user/user_state.dart index 1ef1d49..46422ec 100644 --- a/lib/core/user/user_state.dart +++ b/lib/core/user/user_state.dart @@ -10,7 +10,7 @@ class UserState { static final ValueNotifier userName = ValueNotifier(null); /// 国家码 (navigate / countryCode) static final ValueNotifier navigate = ValueNotifier(null); - /// 是否启用第三方支付(来自 common_info surge.enable_third_party_payment) + /// 是否启用第三方支付(来自 common_info surge.lucky) static final ValueNotifier enableThirdPartyPayment = ValueNotifier(null); diff --git a/lib/features/gallery/gallery_screen.dart b/lib/features/gallery/gallery_screen.dart index 75afa13..5e47be5 100644 --- a/lib/features/gallery/gallery_screen.dart +++ b/lib/features/gallery/gallery_screen.dart @@ -274,18 +274,20 @@ class _GalleryCard extends StatelessWidget { ), child: ClipRRect( borderRadius: BorderRadius.circular(24), - child: mediaItem.imageUrl != null - ? CachedNetworkImage( - imageUrl: mediaItem.imageUrl!, - fit: BoxFit.cover, - placeholder: (_, __) => Container( - color: AppColors.surfaceAlt, - ), - errorWidget: (_, __, ___) => Container( - color: AppColors.surfaceAlt, - ), - ) - : _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!), + child: mediaItem.isVideo + ? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!) + : (mediaItem.imageUrl != null && mediaItem.imageUrl!.isNotEmpty) + ? CachedNetworkImage( + imageUrl: mediaItem.imageUrl!, + fit: BoxFit.cover, + placeholder: (_, __) => Container( + color: AppColors.surfaceAlt, + ), + errorWidget: (_, __, ___) => Container( + color: AppColors.surfaceAlt, + ), + ) + : Container(color: AppColors.surfaceAlt), ), ); }, diff --git a/lib/features/gallery/models/gallery_task_item.dart b/lib/features/gallery/models/gallery_task_item.dart index 8db92f9..cba8b81 100644 --- a/lib/features/gallery/models/gallery_task_item.dart +++ b/lib/features/gallery/models/gallery_task_item.dart @@ -1,14 +1,16 @@ -/// 媒体项:digitize=图片URL,reconfigure=视频URL(需生成封面) +/// 媒体项:reconfigure=imgUrl,类型由 reconnect(imgType) 决定:0=视频,1=图片,其他当图片 class GalleryMediaItem { const GalleryMediaItem({ this.imageUrl, this.videoUrl, }) : assert(imageUrl != null || videoUrl != null); - final String? imageUrl; // digitize - final String? videoUrl; // reconfigure - 视频地址,用于生成封面 + final String? imageUrl; + final String? videoUrl; // 视频地址,用于生成封面 - bool get isVideo => videoUrl != null && (imageUrl == null || imageUrl!.isEmpty); + /// reconnect==0 为视频,1 或其他为图片 + bool get isVideo => + videoUrl != null && (imageUrl == null || imageUrl!.isEmpty); } /// 我的任务项(V2 字段映射) @@ -34,13 +36,19 @@ class GalleryTaskItem { if (item is String) { items.add(GalleryMediaItem(imageUrl: item)); } else if (item is Map) { - final digitize = item['digitize'] as String?; final reconfigure = item['reconfigure'] as String?; - // digitize=图片, reconfigure=视频;优先用图片,否则用视频生成封面 - if (digitize != null && digitize.isNotEmpty) { - items.add(GalleryMediaItem(imageUrl: digitize)); - } else if (reconfigure != null && reconfigure.isNotEmpty) { + if (reconfigure == null || reconfigure.isEmpty) continue; + // reconnect(imgType): 0=视频,1=图片,其他默认当图片 + final reconnect = item['reconnect']; + final imgType = reconnect is int + ? reconnect + : reconnect is num + ? reconnect.toInt() + : 1; + if (imgType == 2) { items.add(GalleryMediaItem(videoUrl: reconfigure)); + } else { + items.add(GalleryMediaItem(imageUrl: reconfigure)); } } } diff --git a/lib/features/generate_video/generate_progress_screen.dart b/lib/features/generate_video/generate_progress_screen.dart index 0a60d57..9626890 100644 --- a/lib/features/generate_video/generate_progress_screen.dart +++ b/lib/features/generate_video/generate_progress_screen.dart @@ -38,27 +38,24 @@ double _progressForState(int? state) { } /// Build GalleryMediaItem from /v1/image/progress response (data = sidekick). -/// curate[].reconfigure = result URL; for img2video use as videoUrl. +/// curate[].reconfigure = imgUrl, reconnect(imgType): 2=视频,1或其他=图片 GalleryMediaItem? _mediaItemFromProgressData(Map data) { final curate = data['curate'] as List?; if (curate == null || curate.isEmpty) return null; final first = curate.first; if (first is! Map) return null; final reconfigure = first['reconfigure'] as String?; - final digitize = first['digitize'] as String?; - final videoUrl = reconfigure?.isNotEmpty == true - ? reconfigure - : digitize?.isNotEmpty == true - ? digitize - : null; - final imageUrl = digitize?.isNotEmpty == true ? digitize : null; - if (videoUrl != null) { - return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl); + 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); } - if (imageUrl != null) { - return GalleryMediaItem(imageUrl: imageUrl); - } - return null; + return GalleryMediaItem(imageUrl: reconfigure); } /// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label) @@ -144,8 +141,7 @@ class _GenerateProgressScreenState extends State { asset: userId, ); if (accountRes.isSuccess && accountRes.data != null) { - final accountData = - accountRes.data as Map?; + final accountData = accountRes.data as Map?; final credits = accountData?['reveal'] as int?; if (credits != null) { UserState.setCredits(credits); @@ -191,7 +187,7 @@ class _GenerateProgressScreenState extends State { _VideoPreview(imagePath: widget.imagePath), const SizedBox(height: AppSpacing.xxl), Text( - 'Video generation may take some time. Please wait patiently.', + 'Generating may take some time. Please wait patiently.', textAlign: TextAlign.center, style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, @@ -214,8 +210,9 @@ class _VideoPreview extends StatelessWidget { @override Widget build(BuildContext context) { - final hasImage = - imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync(); + final hasImage = imagePath != null && + imagePath!.isNotEmpty && + File(imagePath!).existsSync(); return Container( height: 360, @@ -242,7 +239,7 @@ class _VideoPreview extends StatelessWidget { ), const SizedBox(height: AppSpacing.lg), Text( - 'Video Preview', + 'Previewing', style: AppTypography.bodyRegular.copyWith( color: AppColors.textSecondary, ), @@ -276,8 +273,7 @@ class _ProgressSection extends StatelessWidget { const SizedBox(height: AppSpacing.lg), LayoutBuilder( builder: (context, constraints) { - final fillWidth = - constraints.maxWidth * progress.clamp(0.0, 1.0); + final fillWidth = constraints.maxWidth * progress.clamp(0.0, 1.0); return Stack( children: [ Container( diff --git a/lib/features/generate_video/generate_video_screen.dart b/lib/features/generate_video/generate_video_screen.dart index 6d2178b..d0667c7 100644 --- a/lib/features/generate_video/generate_video_screen.dart +++ b/lib/features/generate_video/generate_video_screen.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; +import 'package:flutter/painting.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:http/http.dart' as http; @@ -52,6 +53,11 @@ class _GenerateVideoScreenState extends State { String get _heatmap => _selectedResolution == _Resolution.p480 ? '480p' : '720p'; + bool get _hasVideo { + final url = widget.task?.previewVideoUrl; + return url != null && url.isNotEmpty; + } + @override void initState() { super.initState(); @@ -224,7 +230,7 @@ class _GenerateVideoScreenState extends State { appBar: PreferredSize( preferredSize: const Size.fromHeight(56), child: TopNavBar( - title: 'Generate Video', + title: 'Generate', showBackButton: true, onBack: () => Navigator.of(context).pop(), ), @@ -268,12 +274,12 @@ class _GenerateVideoScreenState extends State { mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _ResolutionToggle( - selected: _selectedResolution, - onChanged: (r) => - setState(() => _selectedResolution = r), - ), - const SizedBox(height: AppSpacing.xxl), + if (_hasVideo) + _ResolutionToggle( + selected: _selectedResolution, + onChanged: (r) => setState(() => _selectedResolution = r), + ), + if (_hasVideo) const SizedBox(height: AppSpacing.xxl), _GenerateButton( onGenerate: _onGenerateButtonTap, isLoading: _isGenerating, @@ -416,13 +422,35 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> { @override Widget build(BuildContext context) { - final isReady = - _controller != null && _controller!.value.isInitialized; + final isReady = _controller != null && _controller!.value.isInitialized; final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; - final hasImage = - widget.imageUrl != null && widget.imageUrl!.isNotEmpty; + final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty; - // Aspect ratio: from video when ready, else 16:9 placeholder + // 图片模式:宽度=组件宽度,高度按图片宽高比自适应 + if (!hasVideo && hasImage) { + return LayoutBuilder( + builder: (context, constraints) { + return Container( + width: constraints.maxWidth, + decoration: BoxDecoration( + color: AppColors.surfaceAlt, + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: AppColors.border, + width: 1, + ), + ), + clipBehavior: Clip.antiAlias, + child: _AspectRatioImage( + imageUrl: widget.imageUrl!, + maxWidth: constraints.maxWidth, + ), + ); + }, + ); + } + + // 视频模式:aspect ratio 来自视频或 16:9 占位 final aspectRatio = isReady && _controller!.value.size.width > 0 && _controller!.value.size.height > 0 @@ -468,7 +496,8 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> { width: double.infinity, height: double.infinity, placeholder: (_, __) => _LoadingOverlay(isLoading: true), - errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false), + errorWidget: (_, __, ___) => + _LoadingOverlay(isLoading: false), ) else _LoadingOverlay(isLoading: false), @@ -484,6 +513,95 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> { } } +/// 图片展示:宽度=组件宽度,高度按图片宽高比自适应 +class _AspectRatioImage extends StatefulWidget { + const _AspectRatioImage({ + required this.imageUrl, + required this.maxWidth, + }); + + final String imageUrl; + final double maxWidth; + + @override + State<_AspectRatioImage> createState() => _AspectRatioImageState(); +} + +class _AspectRatioImageState extends State<_AspectRatioImage> { + double? _aspectRatio; + ImageStream? _stream; + late ImageStreamListener _listener; + + @override + void initState() { + super.initState(); + _listener = ImageStreamListener(_onImageLoaded, onError: _onImageError); + _resolveImage(); + } + + @override + void didUpdateWidget(covariant _AspectRatioImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.imageUrl != widget.imageUrl) { + _stream?.removeListener(_listener); + _aspectRatio = null; + _resolveImage(); + } + } + + void _resolveImage() { + final provider = CachedNetworkImageProvider(widget.imageUrl); + _stream = provider.resolve(const ImageConfiguration()); + _stream!.addListener(_listener); + } + + void _onImageLoaded(ImageInfo info, bool sync) { + if (!mounted) return; + final w = info.image.width.toDouble(); + final h = info.image.height.toDouble(); + if (w > 0 && h > 0) { + setState(() => _aspectRatio = w / h); + } + } + + void _onImageError(dynamic exception, StackTrace? stackTrace) { + if (mounted) setState(() => _aspectRatio = 1); + } + + @override + void dispose() { + _stream?.removeListener(_listener); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ratio = _aspectRatio ?? 1; + final height = widget.maxWidth / ratio; + + return SizedBox( + width: widget.maxWidth, + height: height, + child: CachedNetworkImage( + imageUrl: widget.imageUrl, + fit: BoxFit.contain, + width: widget.maxWidth, + height: height, + placeholder: (_, __) => SizedBox( + width: widget.maxWidth, + height: widget.maxWidth, + child: _LoadingOverlay(isLoading: true), + ), + errorWidget: (_, __, ___) => SizedBox( + width: widget.maxWidth, + height: widget.maxWidth, + child: _LoadingOverlay(isLoading: false), + ), + ), + ); + } +} + class _LoadingOverlay extends StatelessWidget { const _LoadingOverlay({this.isLoading = true}); @@ -578,9 +696,8 @@ class _ResolutionOption extends StatelessWidget { decoration: BoxDecoration( color: isSelected ? AppColors.primary : AppColors.surfaceAlt, borderRadius: BorderRadius.circular(18), - border: isSelected - ? null - : Border.all(color: AppColors.border, width: 1), + border: + isSelected ? null : Border.all(color: AppColors.border, width: 1), boxShadow: isSelected ? [ BoxShadow( @@ -649,7 +766,7 @@ class _GenerateButton extends StatelessWidget { ) else Text( - 'Generate Video', + 'Generate', style: AppTypography.bodyMedium.copyWith( color: AppColors.surface, ), diff --git a/lib/features/generate_video/generation_result_screen.dart b/lib/features/generate_video/generation_result_screen.dart index ac866df..48b33ca 100644 --- a/lib/features/generate_video/generation_result_screen.dart +++ b/lib/features/generate_video/generation_result_screen.dart @@ -130,7 +130,7 @@ class _GenerationResultScreenState extends State { appBar: PreferredSize( preferredSize: const Size.fromHeight(56), child: TopNavBar( - title: 'Video Ready', + title: 'Ready', showBackButton: true, onBack: () => Navigator.of(context).pop(), ), @@ -325,7 +325,7 @@ class _Placeholder extends StatelessWidget { ), const SizedBox(height: AppSpacing.lg), Text( - 'Your video is ready', + 'Your work is ready', style: AppTypography.bodyRegular.copyWith( color: AppColors.surface.withValues(alpha: 0.6), ),