新增:图生图功能

This commit is contained in:
ivan 2026-03-13 22:04:57 +08:00
parent 62f5c9a19b
commit ea14c36d63
12 changed files with 201 additions and 78 deletions

View File

@ -70,7 +70,7 @@
| 1 | `/v1/user/fast_login` | POST | 设备快速登录body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 | | 1 | `/v1/user/fast_login` | POST | 设备快速登录body: digest, resolution(sign), origin(deviceId)。返回 reevaluate(userToken)、asset(uid)、reveal(积分) 等。 |
| 2 | (保存 token、用户信息到 UserState首次登录打点 register | — | — | | 2 | (保存 token、用户信息到 UserState首次登录打点 register | — | — |
| 3 | `/v1/user/referrer` | POST | 归因上报query: sentinel, asset(uid), portalbody: digest, origin。 | | 3 | `/v1/user/referrer` | POST | 归因上报query: sentinel, asset(uid), portalbody: 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登录成功后顺序执行 **调用处**`lib/core/auth/auth_service.dart`init登录成功后顺序执行

View File

@ -6,7 +6,7 @@
## 1. 流程总览 ## 1. 流程总览
- **第三方支付开启**`enable_third_party_payment === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay` - **第三方支付开启**`lucky === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`
- **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。 - **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。
``` ```

View File

@ -20,7 +20,7 @@
## 数据流简述 ## 数据流简述
1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge` 1. 登录后请求 `common_info`,在 `AuthService._saveCommonInfoToState` 中解析 `data.surge`
- 写入 `enable_third_party_payment` 等; - 写入 `lucky` 等;
- 解析 `need_wait``items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。 - 解析 `need_wait``items`,通过 `UserState.setExtConfig(needShowVideoMenuValue: needWait, items: items)` 写入。
2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu``UserState.extConfigItems`,据此决定: 2. 主页 `HomeScreen` 监听 `UserState.needShowVideoMenu``UserState.extConfigItems`,据此决定:
- 是否渲染顶部分类栏; - 是否渲染顶部分类栏;

View File

@ -9,7 +9,7 @@
``` ```
用户点击 Buy 用户点击 Buy
├─ enable_third_party_payment === true 且已登录 ├─ lucky === true 且已登录
│ │ │ │
│ ├─ getPaymentMethods(activityId) │ ├─ getPaymentMethods(activityId)
│ ├─ 弹窗选择支付方式_PaymentMethodDialog │ ├─ 弹窗选择支付方式_PaymentMethodDialog
@ -23,7 +23,7 @@
│ └─ 否则(其他支付方式) │ └─ 否则(其他支付方式)
│ └─ 打开 createPayment 返回的 payUrlconvert在外部浏览器完成支付 │ └─ 打开 createPayment 返回的 payUrlconvert在外部浏览器完成支付
└─ enable_third_party_payment !== true 或未登录 └─ lucky !== true 或未登录
└─ 仅 Android直接调起 Google Play 内购productId = item.code无 createPayment └─ 仅 Android直接调起 Google Play 内购productId = item.code无 createPayment
``` ```
@ -31,10 +31,10 @@
## 2. 支付分支依据 ## 2. 支付分支依据
- **数据来源**`/v1/user/common_info` 响应中的 **surge**JSON 字符串),解析得到 **enable_third_party_payment**。 - **数据来源**`/v1/user/common_info` 响应中的 **surge**JSON 字符串),解析得到 **lucky**。
- **客户端状态**`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。 - **客户端状态**`UserState.enableThirdPartyPayment`(登录后由 AuthService 从 common_info 写入)。
- **分支** - **分支**
- **第三方支付**`enable_third_party_payment == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。 - **第三方支付**`lucky == true` 且 `UserState.userId` 非空 → 走「获取支付方式 → 弹窗选择 → 创建订单 → 按支付方式分支」。
- **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。 - **直接谷歌支付**:否则(未开三方或未登录)→ 仅 Android 下直接调起 Google Play 内购,不调 getPaymentMethods / createPayment。
--- ---
@ -58,7 +58,7 @@
--- ---
## 4. 第三方支付流程(enable_third_party_payment === true ## 4. 第三方支付流程(lucky === true
### 4.1 步骤顺序 ### 4.1 步骤顺序
@ -87,7 +87,7 @@
--- ---
## 5. 直接谷歌支付(enable_third_party_payment !== true ## 5. 直接谷歌支付(lucky !== true
- 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment凭据可用于后续服务端回调。 - 仅 **Android**:调用 **GooglePlayPurchaseService.launchPurchaseAndReturnData(item.code)**,不请求 getPaymentMethods / createPayment凭据可用于后续服务端回调。
- 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。 - 成功/失败通过 SnackBar 与 AdjustEvents 打点;商品 ID 仍为 **item.code**(须与 Play 后台产品 ID 一致)。

View File

@ -9,7 +9,7 @@
1. **设备快速登录**`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。 1. **设备快速登录**`POST /v1/user/fast_login`,拿到 `userToken` 和用户信息。
2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等**首次登录**记录为注册日期。 2. **保存登录态与用户信息**:保存 token、积分、userId、头像、昵称、国家码等**首次登录**记录为注册日期。
3. **归因上报**`POST /v1/user/referrer`,将归因数据(如从 Adjust 获取的 digest上报。 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 登录成功后,后续所有请求需在 Header 中携带 `knight`(即 `User_token` / userToken
@ -93,13 +93,13 @@
- 与 fast_login 的 data 结构类似,含 **surge**extConfig、积分、用户信息等。 - 与 fast_login 的 data 结构类似,含 **surge**extConfig、积分、用户信息等。
- **surge**:字符串,为 JSON**先 JSON 解析再使用** - **surge**:字符串,为 JSON**先 JSON 解析再使用**
- 解析后的对象中,包含 **enable_third_party_payment** 等字段,用于控制第三方支付等能力。 - 解析后的对象中,包含 **lucky** 等字段,用于控制第三方支付等能力。
- 其他字段reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。 - 其他字段reveal、realm、terminal、navigate 等)按需保存到全局变量或状态。
### 客户端必做 ### 客户端必做
- 调用 common_info 并将结果**保存到全局/状态**。 - 调用 common_info 并将结果**保存到全局/状态**。
- 对 **surge****JSON decode**,得到对象后读取并保存 **enable_third_party_payment** 等配置。 - 对 **surge****JSON decode**,得到对象后读取并保存 **lucky** 等配置。
--- ---
@ -117,7 +117,7 @@ APP 启动
→ 成功后打日志 → 成功后打日志
4. GET /v1/user/common_infoquery: sentinel, asset 4. GET /v1/user/common_infoquery: sentinel, asset
→ 将结果保存到全局 → 将结果保存到全局
→ 对 data.surge 做 JSON decode保存 enable_third_party_payment → 对 data.surge 做 JSON decode保存 lucky
→ 之后所有请求 Header 带 knight = userToken → 之后所有请求 Header 带 knight = userToken
``` ```

View File

@ -52,7 +52,7 @@ class AuthService {
return digest.toString().toUpperCase(); return digest.toString().toUpperCase();
} }
/// common_info surge enable_third_party_payment /// common_info surge lucky
static void _saveCommonInfoToState(Map<String, dynamic> data) { static void _saveCommonInfoToState(Map<String, dynamic> data) {
final reveal = data['reveal'] as int?; final reveal = data['reveal'] as int?;
if (reveal != null) UserState.setCredits(reveal); if (reveal != null) UserState.setCredits(reveal);
@ -68,7 +68,7 @@ class AuthService {
try { try {
final surge = json.decode(surgeStr) as Map<String, dynamic>?; final surge = json.decode(surgeStr) as Map<String, dynamic>?;
if (surge != null) { if (surge != null) {
final enable = surge['enable_third_party_payment'] as bool?; final enable = surge['lucky'] as bool?;
UserState.setEnableThirdPartyPayment(enable); UserState.setEnableThirdPartyPayment(enable);
// extConfigneed_wait = Video items = docs/extConfig.md // extConfigneed_wait = Video items = docs/extConfig.md
final needWait = surge['need_wait'] as bool?; final needWait = surge['need_wait'] as bool?;

View File

@ -10,7 +10,7 @@ class UserState {
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null); static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
/// (navigate / countryCode) /// (navigate / countryCode)
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null); static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
/// common_info surge.enable_third_party_payment /// common_info surge.lucky
static final ValueNotifier<bool?> enableThirdPartyPayment = static final ValueNotifier<bool?> enableThirdPartyPayment =
ValueNotifier<bool?>(null); ValueNotifier<bool?>(null);

View File

@ -274,18 +274,20 @@ class _GalleryCard extends StatelessWidget {
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
child: mediaItem.imageUrl != null child: mediaItem.isVideo
? CachedNetworkImage( ? _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!)
imageUrl: mediaItem.imageUrl!, : (mediaItem.imageUrl != null && mediaItem.imageUrl!.isNotEmpty)
fit: BoxFit.cover, ? CachedNetworkImage(
placeholder: (_, __) => Container( imageUrl: mediaItem.imageUrl!,
color: AppColors.surfaceAlt, fit: BoxFit.cover,
), placeholder: (_, __) => Container(
errorWidget: (_, __, ___) => Container( color: AppColors.surfaceAlt,
color: AppColors.surfaceAlt, ),
), errorWidget: (_, __, ___) => Container(
) color: AppColors.surfaceAlt,
: _VideoThumbnailCover(videoUrl: mediaItem.videoUrl!), ),
)
: Container(color: AppColors.surfaceAlt),
), ),
); );
}, },

View File

@ -1,14 +1,16 @@
/// digitize=URLreconfigure=URL /// reconfigure=imgUrl reconnect(imgType) 0=1=
class GalleryMediaItem { class GalleryMediaItem {
const GalleryMediaItem({ const GalleryMediaItem({
this.imageUrl, this.imageUrl,
this.videoUrl, this.videoUrl,
}) : assert(imageUrl != null || videoUrl != null); }) : assert(imageUrl != null || videoUrl != null);
final String? imageUrl; // digitize final String? imageUrl;
final String? videoUrl; // reconfigure - 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 /// V2
@ -34,13 +36,19 @@ class GalleryTaskItem {
if (item is String) { if (item is String) {
items.add(GalleryMediaItem(imageUrl: item)); items.add(GalleryMediaItem(imageUrl: item));
} else if (item is Map<String, dynamic>) { } else if (item is Map<String, dynamic>) {
final digitize = item['digitize'] as String?;
final reconfigure = item['reconfigure'] as String?; final reconfigure = item['reconfigure'] as String?;
// digitize=, reconfigure= if (reconfigure == null || reconfigure.isEmpty) continue;
if (digitize != null && digitize.isNotEmpty) { // reconnect(imgType): 0=1=
items.add(GalleryMediaItem(imageUrl: digitize)); final reconnect = item['reconnect'];
} else if (reconfigure != null && reconfigure.isNotEmpty) { final imgType = reconnect is int
? reconnect
: reconnect is num
? reconnect.toInt()
: 1;
if (imgType == 2) {
items.add(GalleryMediaItem(videoUrl: reconfigure)); items.add(GalleryMediaItem(videoUrl: reconfigure));
} else {
items.add(GalleryMediaItem(imageUrl: reconfigure));
} }
} }
} }

View File

@ -38,27 +38,24 @@ double _progressForState(int? state) {
} }
/// Build GalleryMediaItem from /v1/image/progress response (data = sidekick). /// 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<String, dynamic> data) { GalleryMediaItem? _mediaItemFromProgressData(Map<String, dynamic> data) {
final curate = data['curate'] as List<dynamic>?; final curate = data['curate'] as List<dynamic>?;
if (curate == null || curate.isEmpty) return null; if (curate == null || curate.isEmpty) return null;
final first = curate.first; final first = curate.first;
if (first is! Map<String, dynamic>) return null; if (first is! Map<String, dynamic>) return null;
final reconfigure = first['reconfigure'] as String?; final reconfigure = first['reconfigure'] as String?;
final digitize = first['digitize'] as String?; if (reconfigure == null || reconfigure.isEmpty) return null;
final videoUrl = reconfigure?.isNotEmpty == true final reconnect = first['reconnect'];
? reconfigure final imgType = reconnect is int
: digitize?.isNotEmpty == true ? reconnect
? digitize : reconnect is num
: null; ? reconnect.toInt()
final imageUrl = digitize?.isNotEmpty == true ? digitize : null; : 1;
if (videoUrl != null) { if (imgType == 2) {
return GalleryMediaItem(videoUrl: videoUrl, imageUrl: imageUrl); return GalleryMediaItem(videoUrl: reconfigure);
} }
if (imageUrl != null) { return GalleryMediaItem(imageUrl: reconfigure);
return GalleryMediaItem(imageUrl: imageUrl);
}
return null;
} }
/// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label) /// Generate Video Progress screen - matches Pencil rSN3T (video), Eghqc (label)
@ -144,8 +141,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
asset: userId, asset: userId,
); );
if (accountRes.isSuccess && accountRes.data != null) { if (accountRes.isSuccess && accountRes.data != null) {
final accountData = final accountData = accountRes.data as Map<String, dynamic>?;
accountRes.data as Map<String, dynamic>?;
final credits = accountData?['reveal'] as int?; final credits = accountData?['reveal'] as int?;
if (credits != null) { if (credits != null) {
UserState.setCredits(credits); UserState.setCredits(credits);
@ -191,7 +187,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
_VideoPreview(imagePath: widget.imagePath), _VideoPreview(imagePath: widget.imagePath),
const SizedBox(height: AppSpacing.xxl), const SizedBox(height: AppSpacing.xxl),
Text( Text(
'Video generation may take some time. Please wait patiently.', 'Generating may take some time. Please wait patiently.',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
@ -214,8 +210,9 @@ class _VideoPreview extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final hasImage = final hasImage = imagePath != null &&
imagePath != null && imagePath!.isNotEmpty && File(imagePath!).existsSync(); imagePath!.isNotEmpty &&
File(imagePath!).existsSync();
return Container( return Container(
height: 360, height: 360,
@ -242,7 +239,7 @@ class _VideoPreview extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
'Video Preview', 'Previewing',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@ -276,8 +273,7 @@ class _ProgressSection extends StatelessWidget {
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
LayoutBuilder( LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final fillWidth = final fillWidth = constraints.maxWidth * progress.clamp(0.0, 1.0);
constraints.maxWidth * progress.clamp(0.0, 1.0);
return Stack( return Stack(
children: [ children: [
Container( Container(

View File

@ -2,6 +2,7 @@ import 'dart:io';
import 'package:cached_network_image/cached_network_image.dart'; import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/painting.dart';
import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:flutter_cache_manager/flutter_cache_manager.dart';
import 'package:flutter_lucide/flutter_lucide.dart'; import 'package:flutter_lucide/flutter_lucide.dart';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
@ -52,6 +53,11 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
String get _heatmap => String get _heatmap =>
_selectedResolution == _Resolution.p480 ? '480p' : '720p'; _selectedResolution == _Resolution.p480 ? '480p' : '720p';
bool get _hasVideo {
final url = widget.task?.previewVideoUrl;
return url != null && url.isNotEmpty;
}
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -224,7 +230,7 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Generate Video', title: 'Generate',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
), ),
@ -268,12 +274,12 @@ class _GenerateVideoScreenState extends State<GenerateVideoScreen> {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
_ResolutionToggle( if (_hasVideo)
selected: _selectedResolution, _ResolutionToggle(
onChanged: (r) => selected: _selectedResolution,
setState(() => _selectedResolution = r), onChanged: (r) => setState(() => _selectedResolution = r),
), ),
const SizedBox(height: AppSpacing.xxl), if (_hasVideo) const SizedBox(height: AppSpacing.xxl),
_GenerateButton( _GenerateButton(
onGenerate: _onGenerateButtonTap, onGenerate: _onGenerateButtonTap,
isLoading: _isGenerating, isLoading: _isGenerating,
@ -416,13 +422,35 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isReady = final isReady = _controller != null && _controller!.value.isInitialized;
_controller != null && _controller!.value.isInitialized;
final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty; final hasVideo = widget.videoUrl != null && widget.videoUrl!.isNotEmpty;
final hasImage = final hasImage = widget.imageUrl != null && widget.imageUrl!.isNotEmpty;
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 && final aspectRatio = isReady &&
_controller!.value.size.width > 0 && _controller!.value.size.width > 0 &&
_controller!.value.size.height > 0 _controller!.value.size.height > 0
@ -468,7 +496,8 @@ class _VideoPreviewAreaState extends State<_VideoPreviewArea> {
width: double.infinity, width: double.infinity,
height: double.infinity, height: double.infinity,
placeholder: (_, __) => _LoadingOverlay(isLoading: true), placeholder: (_, __) => _LoadingOverlay(isLoading: true),
errorWidget: (_, __, ___) => _LoadingOverlay(isLoading: false), errorWidget: (_, __, ___) =>
_LoadingOverlay(isLoading: false),
) )
else else
_LoadingOverlay(isLoading: false), _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 { class _LoadingOverlay extends StatelessWidget {
const _LoadingOverlay({this.isLoading = true}); const _LoadingOverlay({this.isLoading = true});
@ -578,9 +696,8 @@ class _ResolutionOption extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: isSelected ? AppColors.primary : AppColors.surfaceAlt, color: isSelected ? AppColors.primary : AppColors.surfaceAlt,
borderRadius: BorderRadius.circular(18), borderRadius: BorderRadius.circular(18),
border: isSelected border:
? null isSelected ? null : Border.all(color: AppColors.border, width: 1),
: Border.all(color: AppColors.border, width: 1),
boxShadow: isSelected boxShadow: isSelected
? [ ? [
BoxShadow( BoxShadow(
@ -649,7 +766,7 @@ class _GenerateButton extends StatelessWidget {
) )
else else
Text( Text(
'Generate Video', 'Generate',
style: AppTypography.bodyMedium.copyWith( style: AppTypography.bodyMedium.copyWith(
color: AppColors.surface, color: AppColors.surface,
), ),

View File

@ -130,7 +130,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
appBar: PreferredSize( appBar: PreferredSize(
preferredSize: const Size.fromHeight(56), preferredSize: const Size.fromHeight(56),
child: TopNavBar( child: TopNavBar(
title: 'Video Ready', title: 'Ready',
showBackButton: true, showBackButton: true,
onBack: () => Navigator.of(context).pop(), onBack: () => Navigator.of(context).pop(),
), ),
@ -325,7 +325,7 @@ class _Placeholder extends StatelessWidget {
), ),
const SizedBox(height: AppSpacing.lg), const SizedBox(height: AppSpacing.lg),
Text( Text(
'Your video is ready', 'Your work is ready',
style: AppTypography.bodyRegular.copyWith( style: AppTypography.bodyRegular.copyWith(
color: AppColors.surface.withValues(alpha: 0.6), color: AppColors.surface.withValues(alpha: 0.6),
), ),