新增:图生图功能
This commit is contained in:
parent
62f5c9a19b
commit
ea14c36d63
@ -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,登录成功后顺序执行)。
|
||||
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
|
||||
## 1. 流程总览
|
||||
|
||||
- **第三方支付开启**(`enable_third_party_payment === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`。
|
||||
- **第三方支付开启**(`lucky === true` 且已登录):先创建订单 → 调起谷歌支付 → 支付成功后回调 `/v1/payment/googlepay`。
|
||||
- **第三方支付关闭或未登录**:仅 Android 直接调起谷歌支付,不创建订单、不回调 googlepay。
|
||||
|
||||
```
|
||||
|
||||
@ -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`,据此决定:
|
||||
- 是否渲染顶部分类栏;
|
||||
|
||||
@ -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 一致)。
|
||||
|
||||
@ -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
|
||||
```
|
||||
|
||||
|
||||
@ -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<String, dynamic> 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<String, dynamic>?;
|
||||
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?;
|
||||
|
||||
@ -10,7 +10,7 @@ class UserState {
|
||||
static final ValueNotifier<String?> userName = ValueNotifier<String?>(null);
|
||||
/// 国家码 (navigate / countryCode)
|
||||
static final ValueNotifier<String?> navigate = ValueNotifier<String?>(null);
|
||||
/// 是否启用第三方支付(来自 common_info surge.enable_third_party_payment)
|
||||
/// 是否启用第三方支付(来自 common_info surge.lucky)
|
||||
static final ValueNotifier<bool?> enableThirdPartyPayment =
|
||||
ValueNotifier<bool?>(null);
|
||||
|
||||
|
||||
@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@ -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<String, dynamic>) {
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> data) {
|
||||
final curate = data['curate'] as List<dynamic>?;
|
||||
if (curate == null || curate.isEmpty) return null;
|
||||
final first = curate.first;
|
||||
if (first is! Map<String, dynamic>) return null;
|
||||
final reconfigure = first['reconfigure'] as String?;
|
||||
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<GenerateProgressScreen> {
|
||||
asset: userId,
|
||||
);
|
||||
if (accountRes.isSuccess && accountRes.data != null) {
|
||||
final accountData =
|
||||
accountRes.data as Map<String, dynamic>?;
|
||||
final accountData = accountRes.data as Map<String, dynamic>?;
|
||||
final credits = accountData?['reveal'] as int?;
|
||||
if (credits != null) {
|
||||
UserState.setCredits(credits);
|
||||
@ -191,7 +187,7 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
_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(
|
||||
|
||||
@ -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<GenerateVideoScreen> {
|
||||
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<GenerateVideoScreen> {
|
||||
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<GenerateVideoScreen> {
|
||||
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,
|
||||
),
|
||||
|
||||
@ -130,7 +130,7 @@ class _GenerationResultScreenState extends State<GenerationResultScreen> {
|
||||
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),
|
||||
),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user