优化:第一版本打包
This commit is contained in:
parent
8cc9fe7ce5
commit
85bed1cda2
15
.metadata
15
.metadata
@ -18,21 +18,6 @@ migration:
|
|||||||
- platform: android
|
- platform: android
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
||||||
- platform: ios
|
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
- platform: linux
|
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
- platform: macos
|
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
- platform: web
|
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
- platform: windows
|
|
||||||
create_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
base_revision: ff37bef603469fb030f2b72995ab929ccfc227f0
|
|
||||||
|
|
||||||
# User provided section
|
# User provided section
|
||||||
|
|
||||||
|
|||||||
@ -0,0 +1,5 @@
|
|||||||
|
package com.funymeeai.funymee_ai
|
||||||
|
|
||||||
|
import io.flutter.embedding.android.FlutterActivity
|
||||||
|
|
||||||
|
class MainActivity : FlutterActivity()
|
||||||
@ -98,6 +98,21 @@
|
|||||||
"detail": ["detail"],
|
"detail": ["detail"],
|
||||||
"videoUrl": ["video", "video_url", "videoUrl", "preview_video"]
|
"videoUrl": ["video", "video_url", "videoUrl", "preview_video"]
|
||||||
},
|
},
|
||||||
|
"itemKeysHome": {},
|
||||||
|
"itemKeysTask": {},
|
||||||
|
"itemsApplyFieldMappingBeforeTaskMapping": true,
|
||||||
|
"defaultItemTitle": "-",
|
||||||
|
"taskItemMapping": {
|
||||||
|
"imageUrl": [ "image"],
|
||||||
|
"previewImage.url": ["image"],
|
||||||
|
"previewVideo.url": [""],
|
||||||
|
"title": ["name"],
|
||||||
|
"templateName": [""],
|
||||||
|
"taskType": ["title"],
|
||||||
|
"ext": ["detail"],
|
||||||
|
"resolution480p.credits": ["resolution480p.credits", "span.padding", "cost_480p", "cost480p"],
|
||||||
|
"resolution720p.credits": ["resolution720p.credits", "factor.padding", "cost_720p", "cost720p", "cost"]
|
||||||
|
},
|
||||||
"defaults": {
|
"defaults": {
|
||||||
"go_run": false,
|
"go_run": false,
|
||||||
"screen": false,
|
"screen": false,
|
||||||
|
|||||||
@ -263,7 +263,7 @@
|
|||||||
"id": "6WRtN",
|
"id": "6WRtN",
|
||||||
"name": "createWrap",
|
"name": "createWrap",
|
||||||
"width": "fill_container",
|
"width": "fill_container",
|
||||||
"height": 71,
|
"height": 80,
|
||||||
"fill": "#00000000",
|
"fill": "#00000000",
|
||||||
"justifyContent": "center",
|
"justifyContent": "center",
|
||||||
"alignItems": "center",
|
"alignItems": "center",
|
||||||
@ -272,18 +272,51 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "aHMps",
|
"id": "aHMps",
|
||||||
"name": "createBtn",
|
"name": "createBtn",
|
||||||
"width": 186,
|
"width": 212,
|
||||||
"height": 40,
|
"height": 50,
|
||||||
"fill": "#FFFFFF4D",
|
"fill": {
|
||||||
"cornerRadius": 999,
|
"type": "gradient",
|
||||||
"effect": {
|
"gradientType": "linear",
|
||||||
"type": "background_blur",
|
"enabled": true,
|
||||||
"radius": 28
|
"rotation": 90,
|
||||||
|
"size": {
|
||||||
|
"height": 1
|
||||||
},
|
},
|
||||||
"gap": 14,
|
"colors": [
|
||||||
|
{
|
||||||
|
"color": "#FFFDE7",
|
||||||
|
"position": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#FDE047",
|
||||||
|
"position": 0.42
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"color": "#F59E0B",
|
||||||
|
"position": 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"cornerRadius": 999,
|
||||||
|
"stroke": {
|
||||||
|
"align": "inside",
|
||||||
|
"thickness": 2,
|
||||||
|
"fill": "#FFFFFFD9"
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"type": "shadow",
|
||||||
|
"shadowType": "outer",
|
||||||
|
"color": "#B4530952",
|
||||||
|
"offset": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 10
|
||||||
|
},
|
||||||
|
"blur": 28
|
||||||
|
},
|
||||||
|
"gap": 12,
|
||||||
"padding": [
|
"padding": [
|
||||||
18,
|
14,
|
||||||
36
|
28
|
||||||
],
|
],
|
||||||
"justifyContent": "center",
|
"justifyContent": "center",
|
||||||
"alignItems": "center",
|
"alignItems": "center",
|
||||||
@ -292,10 +325,25 @@
|
|||||||
"type": "frame",
|
"type": "frame",
|
||||||
"id": "TAocZ",
|
"id": "TAocZ",
|
||||||
"name": "plusCirc",
|
"name": "plusCirc",
|
||||||
"width": 25,
|
"width": 28,
|
||||||
"height": 25,
|
"height": 28,
|
||||||
"fill": "#FFD60A",
|
"fill": "#FFFFFF",
|
||||||
"cornerRadius": 18,
|
"cornerRadius": 20,
|
||||||
|
"stroke": {
|
||||||
|
"align": "inside",
|
||||||
|
"thickness": 1.5,
|
||||||
|
"fill": "#F59E0B99"
|
||||||
|
},
|
||||||
|
"effect": {
|
||||||
|
"type": "shadow",
|
||||||
|
"shadowType": "outer",
|
||||||
|
"color": "#00000014",
|
||||||
|
"offset": {
|
||||||
|
"x": 0,
|
||||||
|
"y": 2
|
||||||
|
},
|
||||||
|
"blur": 6
|
||||||
|
},
|
||||||
"justifyContent": "center",
|
"justifyContent": "center",
|
||||||
"alignItems": "center",
|
"alignItems": "center",
|
||||||
"children": [
|
"children": [
|
||||||
@ -303,11 +351,11 @@
|
|||||||
"type": "icon_font",
|
"type": "icon_font",
|
||||||
"id": "9PFVT",
|
"id": "9PFVT",
|
||||||
"name": "plusIc",
|
"name": "plusIc",
|
||||||
"width": 12,
|
"width": 14,
|
||||||
"height": 12,
|
"height": 14,
|
||||||
"iconFontName": "plus",
|
"iconFontName": "plus",
|
||||||
"iconFontFamily": "lucide",
|
"iconFontFamily": "lucide",
|
||||||
"fill": "#000000ff"
|
"fill": "#B45309"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@ -315,12 +363,12 @@
|
|||||||
"type": "text",
|
"type": "text",
|
||||||
"id": "rR4OC",
|
"id": "rR4OC",
|
||||||
"name": "ctaTxt",
|
"name": "ctaTxt",
|
||||||
"fill": "#FFFFFF",
|
"fill": "#1C1917",
|
||||||
"content": "Create Now",
|
"content": "Create Now",
|
||||||
"fontFamily": "Inter",
|
"fontFamily": "Inter",
|
||||||
"fontSize": 18,
|
"fontSize": 18,
|
||||||
"fontWeight": "700",
|
"fontWeight": "800",
|
||||||
"letterSpacing": 0.3
|
"letterSpacing": 0.4
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -39,6 +39,7 @@ abstract final class ExtConfigDocumentUrls {
|
|||||||
return ExtConfigData.fromJson(
|
return ExtConfigData.fromJson(
|
||||||
Map<String, dynamic>.from(raw),
|
Map<String, dynamic>.from(raw),
|
||||||
schema: schema,
|
schema: schema,
|
||||||
|
fieldMapping: cfg.fieldMapping,
|
||||||
);
|
);
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return null;
|
return null;
|
||||||
|
|||||||
10
lib/core/open_purchase_store.dart
Normal file
10
lib/core/open_purchase_store.dart
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../features/purchase/purchase_screen.dart';
|
||||||
|
|
||||||
|
/// 打开积分商店(充值页)。
|
||||||
|
void openPurchaseStore(BuildContext context) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(builder: (_) => const PurchaseScreen()),
|
||||||
|
);
|
||||||
|
}
|
||||||
48
lib/core/payment/google_play_order_recovery.dart
Normal file
48
lib/core/payment/google_play_order_recovery.dart
Normal file
@ -0,0 +1,48 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
|
|
||||||
|
import '../app_env.dart';
|
||||||
|
import '../user/user_state.dart';
|
||||||
|
|
||||||
|
/// 谷歌内购 **补单**(与 app_client [GooglePlayPurchaseService.runOrderRecovery] 一致):
|
||||||
|
/// [PaymentService.getUnacknowledgedPurchases] → 有 federation 则 [PaymentApi.googlepay] →
|
||||||
|
/// [PaymentService.completeAndConsumePurchase];日志见 `PaymentService`。
|
||||||
|
Future<void> runGooglePlayOrderRecovery() async {
|
||||||
|
if (!Platform.isAndroid) return;
|
||||||
|
final uid = UserState.userId.value;
|
||||||
|
if (uid == null || uid.isEmpty) return;
|
||||||
|
|
||||||
|
final needRefresh = await PaymentService.runOrderRecovery(
|
||||||
|
userId: uid,
|
||||||
|
onPaymentCallback: (federation, sample, merchant, asset) async {
|
||||||
|
final res = await PaymentApi.googlepay(
|
||||||
|
signature: sample,
|
||||||
|
purchaseData: merchant,
|
||||||
|
orderId: federation,
|
||||||
|
userId: asset,
|
||||||
|
app: currentBackendAppType(),
|
||||||
|
);
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
return PaymentResult(
|
||||||
|
isSuccess: false,
|
||||||
|
msg: res.msg.isNotEmpty ? res.msg : 'googlepay failed',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final body = res.data!;
|
||||||
|
final ok = body.creditsAdded == true ||
|
||||||
|
(body.status ?? '').toUpperCase() == 'SUCCESS';
|
||||||
|
return PaymentResult(isSuccess: ok, msg: body.status ?? '');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if (needRefresh) {
|
||||||
|
await UserAccountRefresh.fetchAndNotify(
|
||||||
|
app: currentBackendAppType(),
|
||||||
|
userId: uid,
|
||||||
|
onAccount: (a) {
|
||||||
|
if (a.credits != null) UserState.setCredits(a.credits!);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
59
lib/core/video_file_cache.dart
Normal file
59
lib/core/video_file_cache.dart
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
||||||
|
|
||||||
|
/// 与图片等分开的专用视频磁盘缓存(`flutter_cache_manager`)。
|
||||||
|
final CacheManager funymeeVideoCacheManager = CacheManager(
|
||||||
|
Config(
|
||||||
|
'funymeeVideoCache',
|
||||||
|
stalePeriod: const Duration(days: 14),
|
||||||
|
maxNrOfCacheObjects: 200,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
/// 用 **scheme + host + path** 作缓存键,忽略 **query**(GCS 等签名参数每次变化,否则永远 cache miss)。
|
||||||
|
/// 下载请求仍使用完整 [url](含 query)。
|
||||||
|
String videoCacheKeyForUrl(String url) {
|
||||||
|
final raw = url.trim();
|
||||||
|
final u = Uri.tryParse(raw);
|
||||||
|
if (u == null || !u.hasScheme) return raw;
|
||||||
|
if (u.scheme != 'http' && u.scheme != 'https') return raw;
|
||||||
|
if (u.host.isEmpty) return raw;
|
||||||
|
final path = u.path.isEmpty ? '/' : u.path;
|
||||||
|
return '${u.scheme}://${u.host}$path';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 排除明显非视频的磁盘文件(HTML 错误页、JSON、过小),避免 ExoPlayer
|
||||||
|
/// [UnrecognizedInputFormatException] 反复读坏缓存。
|
||||||
|
Future<bool> videoCachedFileLooksPlayable(File f) async {
|
||||||
|
try {
|
||||||
|
final len = await f.length();
|
||||||
|
if (len < 512) return false;
|
||||||
|
late List<int> head;
|
||||||
|
try {
|
||||||
|
head = await f.openRead(0, 16).first;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (head.length < 12) return false;
|
||||||
|
// HTML / XML 错误页
|
||||||
|
if (head[0] == 0x3C) {
|
||||||
|
final s = String.fromCharCodes(head.take(8));
|
||||||
|
if (s.startsWith('<!') || s.startsWith('<?') || s.startsWith('<h')) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (head[0] == 0x7B) return false; // JSON
|
||||||
|
// MP4/MOV: box size + 'ftyp'
|
||||||
|
if (head.length >= 8) {
|
||||||
|
final tag = String.fromCharCodes(head.sublist(4, 8));
|
||||||
|
if (tag == 'ftyp') return true;
|
||||||
|
}
|
||||||
|
// WebM EBML
|
||||||
|
if (head[0] == 0x1a && head[1] == 0x45 && head[2] == 0xdf) return true;
|
||||||
|
// 较大二进制:交给解码器(避免误判少见封装)
|
||||||
|
return len >= 4096;
|
||||||
|
} catch (_) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -9,7 +9,7 @@ abstract final class PencilTheme {
|
|||||||
static const Color homeTabDivider = Color(0x66FFFFFF);
|
static const Color homeTabDivider = Color(0x66FFFFFF);
|
||||||
static const Color gemYellow = Color(0xFFFFD60A);
|
static const Color gemYellow = Color(0xFFFFD60A);
|
||||||
|
|
||||||
/// Create Now 磨砂 pill
|
/// 旧版 Create Now 磨砂底(当前 UI 已改用金渐变 [PencilCreateNowButton];保留供参考)。
|
||||||
static const Color createPillFill = Color(0x4DFFFFFF);
|
static const Color createPillFill = Color(0x4DFFFFFF);
|
||||||
static const Color createPlusDisc = Color(0xFFFFD60A);
|
static const Color createPlusDisc = Color(0xFFFFD60A);
|
||||||
|
|
||||||
|
|||||||
@ -6,9 +6,9 @@ import 'package:flutter/services.dart';
|
|||||||
import 'package:gal/gal.dart';
|
import 'package:gal/gal.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
|
import '../../widgets/resilient_network_video.dart';
|
||||||
import '../report/report_screen.dart';
|
import '../report/report_screen.dart';
|
||||||
|
|
||||||
/// Generate result: full-bleed media (clipped), top nav, bottom save + report (design `2SyyL`).
|
/// Generate result: full-bleed media (clipped), top nav, bottom save + report (design `2SyyL`).
|
||||||
@ -32,8 +32,6 @@ class GenerateResultScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
||||||
VideoPlayerController? _video;
|
|
||||||
bool _videoInitFailed = false;
|
|
||||||
bool _saving = false;
|
bool _saving = false;
|
||||||
|
|
||||||
bool get _hasUrl {
|
bool get _hasUrl {
|
||||||
@ -43,43 +41,6 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
|||||||
|
|
||||||
bool get _isVideo => _urlLooksLikeVideo(widget.resultUrl);
|
bool get _isVideo => _urlLooksLikeVideo(widget.resultUrl);
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
if (_hasUrl && _isVideo) {
|
|
||||||
_initVideo();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _initVideo() async {
|
|
||||||
final uri = Uri.tryParse(widget.resultUrl.trim());
|
|
||||||
if (uri == null) {
|
|
||||||
setState(() => _videoInitFailed = true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final c = VideoPlayerController.networkUrl(uri)
|
|
||||||
..setLooping(true)
|
|
||||||
..setVolume(1);
|
|
||||||
try {
|
|
||||||
await c.initialize();
|
|
||||||
await c.play();
|
|
||||||
if (!mounted) {
|
|
||||||
await c.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _video = c);
|
|
||||||
} catch (_) {
|
|
||||||
await c.dispose();
|
|
||||||
if (mounted) setState(() => _videoInitFailed = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void dispose() {
|
|
||||||
_video?.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _saveToGallery() async {
|
Future<void> _saveToGallery() async {
|
||||||
if (!_hasUrl || _saving) return;
|
if (!_hasUrl || _saving) return;
|
||||||
setState(() => _saving = true);
|
setState(() => _saving = true);
|
||||||
@ -254,6 +215,32 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
Positioned(
|
||||||
|
top: 0,
|
||||||
|
right: 0,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 6, right: 14),
|
||||||
|
child: Text(
|
||||||
|
'FunyMeeAI',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
letterSpacing: 0.35,
|
||||||
|
color: Colors.white.withValues(alpha: 0.72),
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 8,
|
||||||
|
color: Color(0x99000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.bottomCenter,
|
alignment: Alignment.bottomCenter,
|
||||||
child: _ResultBottomBar(
|
child: _ResultBottomBar(
|
||||||
@ -290,8 +277,17 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (_isVideo) {
|
if (_isVideo) {
|
||||||
if (_videoInitFailed) {
|
final u = widget.resultUrl.trim();
|
||||||
return ColoredBox(
|
return ResilientNetworkVideoCover(
|
||||||
|
key: ValueKey<String>(u),
|
||||||
|
url: u,
|
||||||
|
loadingWidget: const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
failedWidget: ColoredBox(
|
||||||
color: PencilTheme.stone900,
|
color: PencilTheme.stone900,
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@ -300,71 +296,9 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
|||||||
color: Colors.white54,
|
color: Colors.white54,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
}
|
|
||||||
if (_video == null) {
|
|
||||||
return const ColoredBox(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.white54),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final c = _video!;
|
|
||||||
if (!c.value.isInitialized) {
|
|
||||||
return const ColoredBox(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.white54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Match [home_screen] `_HomeItemVideoBackground`: viewport cw×ch, FittedBox.cover, clip overflow.
|
|
||||||
return LayoutBuilder(
|
|
||||||
builder: (context, constraints) {
|
|
||||||
final cw = constraints.maxWidth;
|
|
||||||
final ch = constraints.maxHeight;
|
|
||||||
final w = c.value.size.width;
|
|
||||||
final h = c.value.size.height;
|
|
||||||
if (w <= 0 ||
|
|
||||||
h <= 0 ||
|
|
||||||
!cw.isFinite ||
|
|
||||||
!ch.isFinite ||
|
|
||||||
cw <= 0 ||
|
|
||||||
ch <= 0) {
|
|
||||||
return const ColoredBox(
|
|
||||||
color: Colors.black,
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(color: Colors.white54),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: ClipRect(
|
|
||||||
child: SizedBox(
|
|
||||||
width: cw,
|
|
||||||
height: ch,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
child: SizedBox(
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
child: VideoPlayer(c),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return SizedBox.expand(
|
return SizedBox.expand(
|
||||||
child: CachedNetworkImage(
|
child: CachedNetworkImage(
|
||||||
imageUrl: widget.resultUrl.trim(),
|
imageUrl: widget.resultUrl.trim(),
|
||||||
|
|||||||
@ -7,15 +7,17 @@ import 'package:client_proxy_framework/client_proxy_framework.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
|
||||||
|
|
||||||
import '../../core/app_env.dart';
|
import '../../core/app_env.dart';
|
||||||
|
import '../../core/open_purchase_store.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
import 'generate_progress_screen.dart';
|
import '../../widgets/resilient_network_video.dart';
|
||||||
|
import 'generate_result_screen.dart';
|
||||||
|
|
||||||
/// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。
|
/// `EYsUi` 生成图片页 — 左 112×108 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。
|
||||||
|
/// 提交后在同页轮询进度,完成后 [pushReplacement] 至 [GenerateResultScreen]。
|
||||||
/// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。
|
/// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。
|
||||||
/// [template] 来自首页选中的 [ExtConfigItem]。
|
/// [template] 来自首页选中的 [ExtConfigItem]。
|
||||||
class GenerateScreen extends StatefulWidget {
|
class GenerateScreen extends StatefulWidget {
|
||||||
@ -31,12 +33,20 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
final _picker = ImagePicker();
|
final _picker = ImagePicker();
|
||||||
File? _picked;
|
File? _picked;
|
||||||
File? _picked2;
|
File? _picked2;
|
||||||
|
|
||||||
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
||||||
String _outputSize = '720p';
|
String _outputSize = '720p';
|
||||||
bool _busy = false;
|
|
||||||
|
|
||||||
VideoPlayerController? _previewVideo;
|
/// 从开始点击到任务结束(成功跳转或失败):按钮保持同色加载态。
|
||||||
bool _previewVideoFailed = false;
|
bool _generating = false;
|
||||||
|
|
||||||
|
ImageProgressPollHandle? _pollHandle;
|
||||||
|
String _genStatus = '';
|
||||||
|
int _genProgress = 0;
|
||||||
|
String? _genResultUrl;
|
||||||
|
String? _genError;
|
||||||
|
bool _pollNavigated = false;
|
||||||
|
String? _pollTaskId;
|
||||||
|
|
||||||
static const double _slotW = 112;
|
static const double _slotW = 112;
|
||||||
static const double _slotH = 108;
|
static const double _slotH = 108;
|
||||||
@ -70,22 +80,69 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
return (d != null && d.isNotEmpty) ? d : null;
|
return (d != null && d.isNotEmpty) ? d : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 对应 `ext`(profile):仅当 params 与 detail 同时存在时传 detail,避免与 taskType 重复。
|
/// 对应 create-task body 的 `ext`(换皮如 `profile`)。
|
||||||
|
/// - [ExtConfigItem.detail] 非空时优先作为 `ext`;
|
||||||
|
/// - 否则若 [ExtConfigItem.params] 非空且**未**作为 [taskType] 使用(与 [_taskTypeForCreateTask] 不同),则传 `params`(列表项 `ext` 经 [ExtConfigItem.fromTaskItem] 常落在 `params`)。
|
||||||
|
/// 避免与 `taskType` 重复传同一字符串。
|
||||||
String? get _extForCreateTask {
|
String? get _extForCreateTask {
|
||||||
final p = widget.template?.params?.trim();
|
|
||||||
final d = widget.template?.detail?.trim();
|
final d = widget.template?.detail?.trim();
|
||||||
if (p != null && p.isNotEmpty && d != null && d.isNotEmpty) return d;
|
if (d != null && d.isNotEmpty) return d;
|
||||||
|
final p = widget.template?.params?.trim();
|
||||||
|
if (p == null || p.isEmpty) return null;
|
||||||
|
final tt = _taskTypeForCreateTask;
|
||||||
|
if (tt == null || tt != p) return p;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 仅当模板同时提供 [ExtConfigItem.cost480p]、[ExtConfigItem.cost720p](均大于 0)时展示 480p/720p 切换。
|
||||||
|
bool get _showResolutionToggles {
|
||||||
|
final t = widget.template;
|
||||||
|
if (t == null) return false;
|
||||||
|
final c480 = t.cost480p;
|
||||||
|
final c720 = t.cost720p;
|
||||||
|
return c480 != null && c480 > 0 && c720 != null && c720 > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无双档积分信息时固定 [_outputSize]:仅有 720p 档、仅有 480p 档、或仅有通用 [ExtConfigItem.cost] 时不再允许切换。
|
||||||
|
void _syncOutputSizeForTemplate() {
|
||||||
|
final t = widget.template;
|
||||||
|
if (t == null) {
|
||||||
|
_outputSize = '720p';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final has480 = t.cost480p != null && t.cost480p! > 0;
|
||||||
|
final has720 = t.cost720p != null && t.cost720p! > 0;
|
||||||
|
if (has480 && has720) return;
|
||||||
|
if (has720) {
|
||||||
|
_outputSize = '720p';
|
||||||
|
} else if (has480) {
|
||||||
|
_outputSize = '480p';
|
||||||
|
} else {
|
||||||
|
_outputSize = '720p';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_syncOutputSizeForTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(covariant GenerateScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.template != widget.template) {
|
||||||
|
_syncOutputSizeForTemplate();
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickSlot(int slot) async {
|
Future<void> _pickSlot(int slot) async {
|
||||||
|
if (_generating) return;
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final source = await _showPickImageSourceSheet(context);
|
final source = await _showPickImageSourceSheet(context);
|
||||||
if (source == null || !mounted) return;
|
if (source == null || !mounted) return;
|
||||||
final x = await _picker.pickImage(
|
final x = await _picker.pickImage(source: source, imageQuality: 92);
|
||||||
source: source,
|
|
||||||
imageQuality: 92,
|
|
||||||
);
|
|
||||||
if (x == null || !mounted) return;
|
if (x == null || !mounted) return;
|
||||||
setState(() {
|
setState(() {
|
||||||
if (slot == 0) {
|
if (slot == 0) {
|
||||||
@ -180,9 +237,14 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
Future<void> _start() async {
|
Future<void> _start() async {
|
||||||
final uid = UserState.userId.value;
|
final uid = UserState.userId.value;
|
||||||
if (uid == null || uid.isEmpty) {
|
if (uid == null || uid.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('Please sign in first.')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('Please sign in first.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final need = _estimatedCost;
|
||||||
|
if (UserState.credits.value < need) {
|
||||||
|
openPurchaseStore(context);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (_needTwoImages) {
|
if (_needTwoImages) {
|
||||||
@ -202,7 +264,11 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() => _busy = true);
|
setState(() {
|
||||||
|
_generating = true;
|
||||||
|
_genError = null;
|
||||||
|
});
|
||||||
|
var pollStarted = false;
|
||||||
try {
|
try {
|
||||||
final ImagePresignedUploadCreateTaskResult result;
|
final ImagePresignedUploadCreateTaskResult result;
|
||||||
if (_needTwoImages) {
|
if (_needTwoImages) {
|
||||||
@ -252,22 +318,94 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
Navigator.of(context).pushReplacement(
|
pollStarted = true;
|
||||||
MaterialPageRoute<void>(
|
_startProgressPoll(taskId);
|
||||||
builder: (_) => GenerateProgressScreen(
|
|
||||||
taskId: taskId,
|
|
||||||
localPreviewPath: result.fileUsedForUpload.path,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
SnackBar(content: Text('$e')),
|
context,
|
||||||
);
|
).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busy = false);
|
if (mounted && !pollStarted) {
|
||||||
|
setState(() => _generating = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startProgressPoll(String taskId) {
|
||||||
|
_pollHandle?.cancel();
|
||||||
|
_pollTaskId = taskId;
|
||||||
|
setState(() {
|
||||||
|
_genStatus = '';
|
||||||
|
_genProgress = 0;
|
||||||
|
_genResultUrl = null;
|
||||||
|
_genError = null;
|
||||||
|
_pollNavigated = false;
|
||||||
|
});
|
||||||
|
_pollHandle = ImageProgressPoll.start(
|
||||||
|
app: currentBackendAppType(),
|
||||||
|
taskId: taskId,
|
||||||
|
userId: UserState.userId.value,
|
||||||
|
interval: const Duration(seconds: 5),
|
||||||
|
onTick: _onProgressTick,
|
||||||
|
onTransientNetworkFailure: (n, max) {
|
||||||
|
if (!mounted || _pollNavigated) return;
|
||||||
|
setState(() {
|
||||||
|
_genStatus = 'Reconnecting… ($n/$max)';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFatalError: (msg) {
|
||||||
|
if (!mounted || _pollNavigated) return;
|
||||||
|
_pollHandle?.cancel();
|
||||||
|
_pollHandle = null;
|
||||||
|
setState(() {
|
||||||
|
_genError = msg;
|
||||||
|
_generating = false;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onProgressTick(ProgressPollTick tick) {
|
||||||
|
if (!mounted || _pollNavigated) return;
|
||||||
|
final res = tick.response;
|
||||||
|
if (!res.isSuccess || res.data == null) {
|
||||||
|
setState(
|
||||||
|
() => _genError = res.msg.isNotEmpty ? res.msg : 'Progress error',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final p = res.data!;
|
||||||
|
setState(() {
|
||||||
|
_genError = null;
|
||||||
|
_genStatus = p.status ?? '';
|
||||||
|
_genProgress = p.progress ?? 0;
|
||||||
|
_genResultUrl = p.resultUrl;
|
||||||
|
});
|
||||||
|
|
||||||
|
final doneSuccess = ProgressPollSemantics.isProgressSuccess(p);
|
||||||
|
if (doneSuccess) {
|
||||||
|
_pollNavigated = true;
|
||||||
|
_pollHandle?.cancel();
|
||||||
|
_pollHandle = null;
|
||||||
|
final url = _genResultUrl ?? '';
|
||||||
|
final tid = _pollTaskId ?? '';
|
||||||
|
Navigator.of(context).pushReplacement(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => GenerateResultScreen(taskId: tid, resultUrl: url),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ProgressPollSemantics.isProgressFailure(p)) {
|
||||||
|
_pollHandle?.cancel();
|
||||||
|
_pollHandle = null;
|
||||||
|
setState(() {
|
||||||
|
_genError ??= 'Task failed (${p.status})';
|
||||||
|
_generating = false;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,68 +425,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
return u != null && _urlLooksLikeVideo(u);
|
return u != null && _urlLooksLikeVideo(u);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pauseThenDispose(VideoPlayerController c) async {
|
|
||||||
try {
|
|
||||||
await c.pause();
|
|
||||||
} catch (_) {}
|
|
||||||
try {
|
|
||||||
await c.dispose();
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _disposePreviewVideo() {
|
|
||||||
final c = _previewVideo;
|
|
||||||
_previewVideo = null;
|
|
||||||
_previewVideoFailed = false;
|
|
||||||
if (c != null) {
|
|
||||||
unawaited(_pauseThenDispose(c));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _tryInitPreviewVideo() async {
|
|
||||||
final playUrl = _previewPlayUrl(widget.template);
|
|
||||||
if (playUrl == null || !_urlLooksLikeVideo(playUrl)) return;
|
|
||||||
final uri = Uri.tryParse(playUrl);
|
|
||||||
if (uri == null) {
|
|
||||||
if (mounted) setState(() => _previewVideoFailed = true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final c = VideoPlayerController.networkUrl(uri)
|
|
||||||
..setLooping(true)
|
|
||||||
..setVolume(1);
|
|
||||||
try {
|
|
||||||
await c.initialize();
|
|
||||||
await c.play();
|
|
||||||
if (!mounted) {
|
|
||||||
await c.dispose();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() => _previewVideo = c);
|
|
||||||
} catch (_) {
|
|
||||||
await c.dispose();
|
|
||||||
if (mounted) setState(() => _previewVideoFailed = true);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
unawaited(_tryInitPreviewVideo());
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void didUpdateWidget(covariant GenerateScreen oldWidget) {
|
|
||||||
super.didUpdateWidget(oldWidget);
|
|
||||||
if (oldWidget.template?.videoUrl != widget.template?.videoUrl ||
|
|
||||||
oldWidget.template?.image != widget.template?.image) {
|
|
||||||
_disposePreviewVideo();
|
|
||||||
unawaited(_tryInitPreviewVideo());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposePreviewVideo();
|
_pollHandle?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -372,6 +451,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
return c > 0 ? c : fallback;
|
return c > 0 ? c : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 进度条已出现(轮询开始)时隐藏底部档位/按钮/积分信息。
|
||||||
|
bool get _showProgressBar => _pollHandle != null;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final credits = UserState.credits.value;
|
final credits = UserState.credits.value;
|
||||||
@ -423,7 +505,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_needTwoImages ? _leftTwoSlotsColumn() : _leftSingleSlotColumn(),
|
_needTwoImages
|
||||||
|
? _leftTwoSlotsColumn()
|
||||||
|
: _leftSingleSlotColumn(),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
_equalsColumn(),
|
_equalsColumn(),
|
||||||
const SizedBox(width: 14),
|
const SizedBox(width: 14),
|
||||||
@ -436,7 +520,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Row(
|
if (!_showProgressBar && _showResolutionToggles)
|
||||||
|
IgnorePointer(
|
||||||
|
ignoring: _generating,
|
||||||
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
_resoChip(
|
_resoChip(
|
||||||
@ -452,26 +539,75 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
if (_genError != null) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
_genError!,
|
||||||
|
style: GoogleFonts.inter(color: Colors.red),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
] else if (_showProgressBar) ...[
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
LinearProgressIndicator(
|
||||||
|
value: _genProgress > 0 ? _genProgress / 100 : null,
|
||||||
|
color: PencilTheme.underlineGold,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Text(
|
||||||
|
_genStatus.isEmpty ? 'Processing…' : _genStatus,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
color: PencilTheme.stone600,
|
||||||
|
),
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
'$_genProgress%',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (!_showProgressBar) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: PencilTheme.underlineGold,
|
backgroundColor: PencilTheme.underlineGold,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: PencilTheme.underlineGold,
|
||||||
|
disabledForegroundColor: Colors.white,
|
||||||
minimumSize: const Size.fromHeight(54),
|
minimumSize: const Size.fromHeight(54),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onPressed: _busy ? null : _start,
|
onPressed: _generating ? null : _start,
|
||||||
child: _busy
|
child: _generating
|
||||||
? const SizedBox(
|
? Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const SizedBox(
|
||||||
height: 22,
|
height: 22,
|
||||||
width: 22,
|
width: 22,
|
||||||
child: CircularProgressIndicator(
|
child: CircularProgressIndicator(
|
||||||
strokeWidth: 2,
|
strokeWidth: 2,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
'Loading',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
)
|
)
|
||||||
: Text(
|
: Text(
|
||||||
'Start',
|
'Start',
|
||||||
@ -492,13 +628,22 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
InkWell(
|
||||||
|
onTap: () => openPurchaseStore(context),
|
||||||
|
child: Text(
|
||||||
'Balance · ${credits.toStringAsFixed(2)}',
|
'Balance · ${credits.toStringAsFixed(2)}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
color: PencilTheme.inkSoft,
|
color: PencilTheme.inkSoft,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: PencilTheme.inkSoft.withValues(
|
||||||
|
alpha: 0.45,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -543,9 +688,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
child: Column(
|
child: Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [_imageSlot(slotIndex: 0, label: 'Image 1')],
|
||||||
_imageSlot(slotIndex: 0, label: 'Image 1'),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -615,54 +758,18 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 视频预览:与 [GenerateResultScreen] / 首页背景一致,循环播放;失败则回退静态图。
|
/// 视频预览:[ResilientNetworkVideoCover] 与首页一致处理 416 / 坏缓存并重试;失败则回退静态图。
|
||||||
Widget _buildPreviewMediaLayer(String url, String? fix) {
|
Widget _buildPreviewMediaLayer(String url, String? fix) {
|
||||||
if (_previewIsVideo) {
|
if (_previewIsVideo) {
|
||||||
if (_previewVideoFailed) {
|
final playUrl = _previewPlayUrl(widget.template);
|
||||||
return _buildPreviewStaticImageLayer(url, fix);
|
if (playUrl == null || playUrl.isEmpty) {
|
||||||
|
return _previewPlaceholder();
|
||||||
}
|
}
|
||||||
final c = _previewVideo;
|
return ResilientNetworkVideoCover(
|
||||||
if (c == null || !c.value.isInitialized) {
|
key: ValueKey<String>(playUrl),
|
||||||
return _previewPlaceholder(loading: true);
|
url: playUrl,
|
||||||
}
|
loadingWidget: _previewPlaceholder(loading: true),
|
||||||
return LayoutBuilder(
|
failedWidget: _buildPreviewStaticImageLayer(url, fix),
|
||||||
builder: (context, constraints) {
|
|
||||||
final cw = constraints.maxWidth;
|
|
||||||
final ch = constraints.maxHeight;
|
|
||||||
final w = c.value.size.width;
|
|
||||||
final h = c.value.size.height;
|
|
||||||
if (w <= 0 ||
|
|
||||||
h <= 0 ||
|
|
||||||
!cw.isFinite ||
|
|
||||||
!ch.isFinite ||
|
|
||||||
cw <= 0 ||
|
|
||||||
ch <= 0) {
|
|
||||||
return _previewPlaceholder(loading: true);
|
|
||||||
}
|
|
||||||
return Stack(
|
|
||||||
fit: StackFit.expand,
|
|
||||||
children: [
|
|
||||||
Positioned.fill(
|
|
||||||
child: ClipRect(
|
|
||||||
child: SizedBox(
|
|
||||||
width: cw,
|
|
||||||
height: ch,
|
|
||||||
child: FittedBox(
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
alignment: Alignment.center,
|
|
||||||
clipBehavior: Clip.none,
|
|
||||||
child: SizedBox(
|
|
||||||
width: w,
|
|
||||||
height: h,
|
|
||||||
child: VideoPlayer(c),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return _buildPreviewStaticImageLayer(url, fix);
|
return _buildPreviewStaticImageLayer(url, fix);
|
||||||
@ -747,10 +854,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
side: const BorderSide(
|
side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5),
|
||||||
color: PencilTheme.genSlotBorder,
|
|
||||||
width: 1.5,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: () => _pickSlot(slotIndex),
|
onTap: () => _pickSlot(slotIndex),
|
||||||
@ -804,8 +908,11 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.auto_awesome,
|
Icon(
|
||||||
size: 18, color: PencilTheme.profileAvatarIcon),
|
Icons.auto_awesome,
|
||||||
|
size: 18,
|
||||||
|
color: PencilTheme.profileAvatarIcon,
|
||||||
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'Upload tips',
|
'Upload tips',
|
||||||
@ -819,7 +926,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
'Upload JPG or PNG (≤ 5 MB each, up to 2). You can use the camera or photo library. Use clear, front-facing photos when possible.',
|
'Upload JPG or PNG (≤ 5 MB each). You can use the camera or photo library. Use clear, front-facing photos when possible.',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@ -834,7 +941,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
|
|
||||||
Widget _resoChip(String t, bool on, VoidCallback fn) {
|
Widget _resoChip(String t, bool on, VoidCallback fn) {
|
||||||
return Material(
|
return Material(
|
||||||
color: on ? PencilTheme.underlineGold.withValues(alpha: 0.2) : Colors.white,
|
color: on
|
||||||
|
? PencilTheme.underlineGold.withValues(alpha: 0.2)
|
||||||
|
: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
side: BorderSide(
|
side: BorderSide(
|
||||||
|
|||||||
85
lib/features/history/history_media_save.dart
Normal file
85
lib/features/history/history_media_save.dart
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:gal/gal.dart';
|
||||||
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// 与 [GenerateResultScreen] 保存相册逻辑一致,供 History 卡片 Download 使用。
|
||||||
|
Future<void> saveHistoryMediaToGallery({
|
||||||
|
required BuildContext context,
|
||||||
|
required String taskId,
|
||||||
|
required String resultUrl,
|
||||||
|
}) async {
|
||||||
|
final u = resultUrl.trim();
|
||||||
|
if (!u.startsWith('http://') && !u.startsWith('https://')) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Invalid media URL',
|
||||||
|
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final ok = await Gal.hasAccess();
|
||||||
|
if (!ok) {
|
||||||
|
await Gal.requestAccess();
|
||||||
|
}
|
||||||
|
final uri = Uri.parse(u);
|
||||||
|
final res = await http.get(uri);
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw HttpException('HTTP ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final isVideo = historyUrlLooksLikeVideo(u);
|
||||||
|
final ext = isVideo ? historyGuessVideoExt(u) : '.jpg';
|
||||||
|
final file = File(
|
||||||
|
'${Directory.systemTemp.path}/funymee_hist_${taskId}_$ext',
|
||||||
|
);
|
||||||
|
await file.writeAsBytes(res.bodyBytes);
|
||||||
|
if (isVideo) {
|
||||||
|
await Gal.putVideo(file.path);
|
||||||
|
} else {
|
||||||
|
await Gal.putImage(file.path);
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Saved to Photos',
|
||||||
|
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on GalException catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(e.type.message)));
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('Save failed: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool historyUrlLooksLikeVideo(String url) {
|
||||||
|
if (url.isEmpty) return false;
|
||||||
|
final lower = url.toLowerCase();
|
||||||
|
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi'];
|
||||||
|
return hints.any((h) => lower.contains(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
String historyGuessVideoExt(String url) {
|
||||||
|
final lower = url.toLowerCase();
|
||||||
|
if (lower.contains('.webm')) return '.webm';
|
||||||
|
if (lower.contains('.mov')) return '.mov';
|
||||||
|
if (lower.contains('.m3u8')) return '.mp4';
|
||||||
|
return '.mp4';
|
||||||
|
}
|
||||||
@ -9,6 +9,8 @@ import '../../design/pencil_theme.dart';
|
|||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
import '../generate/generate_result_screen.dart';
|
import '../generate/generate_result_screen.dart';
|
||||||
import 'credit_record_tab.dart';
|
import 'credit_record_tab.dart';
|
||||||
|
import 'history_media_save.dart';
|
||||||
|
import 'history_task_progress_screen.dart';
|
||||||
import 'widgets/history_grid_card.dart';
|
import 'widgets/history_grid_card.dart';
|
||||||
|
|
||||||
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
|
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
|
||||||
@ -36,6 +38,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
String? _error;
|
String? _error;
|
||||||
List<MyTaskItem> _items = [];
|
List<MyTaskItem> _items = [];
|
||||||
Map<String, String> _localCovers = {};
|
Map<String, String> _localCovers = {};
|
||||||
|
final Set<String> _downloadingTaskIds = {};
|
||||||
VoidCallback? _cancelLoginWait;
|
VoidCallback? _cancelLoginWait;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -253,11 +256,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
(context, i) {
|
(context, i) {
|
||||||
final t = _items[i];
|
final t = _items[i];
|
||||||
final id = t.taskId ?? '';
|
final id = t.taskId ?? '';
|
||||||
|
final raw = myTaskListingRaw(t);
|
||||||
|
final display = listingDisplayFromApi(raw);
|
||||||
|
final canDl = myTaskCanShowDownload(t);
|
||||||
|
final statusLabel = myTaskStatusLabel(t);
|
||||||
return HistoryGridCard(
|
return HistoryGridCard(
|
||||||
item: t,
|
item: t,
|
||||||
localCoverPath:
|
localCoverPath:
|
||||||
id.isEmpty ? null : _localCovers[id],
|
id.isEmpty ? null : _localCovers[id],
|
||||||
|
showDownload: canDl,
|
||||||
|
statusLabel: statusLabel,
|
||||||
|
isDownloading:
|
||||||
|
id.isNotEmpty && _downloadingTaskIds.contains(id),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
// 有结果 URL 优先进预览(与列表「完成」态以地址为准一致)
|
||||||
|
if (myTaskHasRemoteResultUrl(t)) {
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => GenerateResultScreen(
|
builder: (_) => GenerateResultScreen(
|
||||||
@ -266,8 +280,55 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (myTaskIsInProgress(t)) {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) =>
|
||||||
|
HistoryTaskProgressScreen(taskId: id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (galleryListingIsFinishedSuccess(raw, display)) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Media is not ready yet. Pull to refresh.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
galleryListingBlockedHint(raw, display),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
onDownload: () {},
|
onDownload: canDl
|
||||||
|
? () async {
|
||||||
|
final u = t.resultUrl?.trim() ?? '';
|
||||||
|
if (u.isEmpty || id.isEmpty) return;
|
||||||
|
setState(() => _downloadingTaskIds.add(id));
|
||||||
|
try {
|
||||||
|
await saveHistoryMediaToGallery(
|
||||||
|
context: context,
|
||||||
|
taskId: id,
|
||||||
|
resultUrl: u,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setState(
|
||||||
|
() => _downloadingTaskIds.remove(id),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
childCount: _items.length,
|
childCount: _items.length,
|
||||||
|
|||||||
@ -1,5 +1,3 @@
|
|||||||
import 'dart:io';
|
|
||||||
|
|
||||||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
@ -8,86 +6,87 @@ import '../../core/app_env.dart';
|
|||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
import 'generate_result_screen.dart';
|
import '../generate/generate_result_screen.dart';
|
||||||
|
|
||||||
/// `YoZaK` 生成中 — 与 EYsUi 同款顶栏结构,标题「生成中」。
|
/// 从 My History 点「生成中」任务进入:轮询 [ImageApi.getProgress],完成后进入 [GenerateResultScreen]。
|
||||||
class GenerateProgressScreen extends StatefulWidget {
|
///
|
||||||
const GenerateProgressScreen({
|
/// 与 app_client [GenerateProgressScreen] 行为对齐(权威数据源为 `/v1/image/progress`)。
|
||||||
super.key,
|
class HistoryTaskProgressScreen extends StatefulWidget {
|
||||||
required this.taskId,
|
const HistoryTaskProgressScreen({super.key, required this.taskId});
|
||||||
this.localPreviewPath,
|
|
||||||
});
|
|
||||||
|
|
||||||
final String taskId;
|
final String taskId;
|
||||||
final String? localPreviewPath;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
|
State<HistoryTaskProgressScreen> createState() =>
|
||||||
|
_HistoryTaskProgressScreenState();
|
||||||
}
|
}
|
||||||
|
|
||||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
class _HistoryTaskProgressScreenState extends State<HistoryTaskProgressScreen> {
|
||||||
ImageProgressPollHandle? _pollHandle;
|
ImageProgressPollHandle? _pollHandle;
|
||||||
String _status = '';
|
String _genStatus = '';
|
||||||
int _progress = 0;
|
int _genProgress = 0;
|
||||||
String? _resultUrl;
|
String? _genResultUrl;
|
||||||
String? _error;
|
String? _genError;
|
||||||
bool _navigated = false;
|
bool _navigated = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_startPoll();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _startPoll() {
|
||||||
|
_pollHandle?.cancel();
|
||||||
_pollHandle = ImageProgressPoll.start(
|
_pollHandle = ImageProgressPoll.start(
|
||||||
app: currentBackendAppType(),
|
app: currentBackendAppType(),
|
||||||
taskId: widget.taskId,
|
taskId: widget.taskId,
|
||||||
userId: UserState.userId.value,
|
userId: UserState.userId.value,
|
||||||
interval: const Duration(seconds: 5),
|
interval: const Duration(seconds: 5),
|
||||||
onTick: _onProgressTick,
|
onTick: _onTick,
|
||||||
onTransientNetworkFailure: (n, max) {
|
|
||||||
if (!mounted || _navigated) return;
|
|
||||||
setState(() {
|
|
||||||
_status = 'Reconnecting… ($n/$max)';
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onFatalError: (msg) {
|
onFatalError: (msg) {
|
||||||
if (!mounted || _navigated) return;
|
if (!mounted || _navigated) return;
|
||||||
setState(() => _error = msg);
|
setState(() => _genError = msg);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onProgressTick(ProgressPollTick tick) {
|
void _onTick(ProgressPollTick tick) {
|
||||||
if (!mounted || _navigated) return;
|
if (!mounted || _navigated) return;
|
||||||
final res = tick.response;
|
final res = tick.response;
|
||||||
if (!res.isSuccess || res.data == null) {
|
if (!res.isSuccess || res.data == null) {
|
||||||
setState(() => _error = res.msg.isNotEmpty ? res.msg : 'Progress error');
|
setState(() => _genError = res.msg.isNotEmpty ? res.msg : 'Progress error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final p = res.data!;
|
final p = res.data!;
|
||||||
setState(() {
|
setState(() {
|
||||||
_status = p.status ?? '';
|
_genError = null;
|
||||||
_progress = p.progress ?? 0;
|
_genStatus = p.status ?? '';
|
||||||
_resultUrl = p.resultUrl;
|
_genProgress = p.progress ?? 0;
|
||||||
|
_genResultUrl = p.resultUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
final doneSuccess = ProgressPollSemantics.isSuccessTerminal(p.status) ||
|
if (ProgressPollSemantics.isProgressSuccess(p)) {
|
||||||
ProgressPollSemantics.hasUsableResultUrl(p.resultUrl);
|
|
||||||
if (doneSuccess) {
|
|
||||||
_navigated = true;
|
_navigated = true;
|
||||||
_pollHandle?.cancel();
|
_pollHandle?.cancel();
|
||||||
|
_pollHandle = null;
|
||||||
|
final url = _genResultUrl ?? '';
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => GenerateResultScreen(
|
builder: (_) => GenerateResultScreen(
|
||||||
taskId: widget.taskId,
|
taskId: widget.taskId,
|
||||||
resultUrl: _resultUrl ?? '',
|
resultUrl: url,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ProgressPollSemantics.isTerminalStatus(p.status) &&
|
if (ProgressPollSemantics.isProgressFailure(p)) {
|
||||||
ProgressPollSemantics.isFailureTerminal(p.status)) {
|
_pollHandle?.cancel();
|
||||||
setState(() => _error ??= 'Task failed (${p.status})');
|
_pollHandle = null;
|
||||||
|
setState(() {
|
||||||
|
_genError ??= 'Task failed (${p.status})';
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -106,11 +105,11 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
bottom: false,
|
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
padding: const EdgeInsets.fromLTRB(2, 0, 14, 8),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 56,
|
height: 56,
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -138,41 +137,34 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(24),
|
padding: const EdgeInsets.symmetric(horizontal: 24),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
if (widget.localPreviewPath != null)
|
if (_genError != null) ...[
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 1,
|
|
||||||
child: Image.file(
|
|
||||||
File(widget.localPreviewPath!),
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
else
|
|
||||||
const SizedBox(height: 48),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
if (_error != null)
|
|
||||||
Text(
|
Text(
|
||||||
_error!,
|
_genError!,
|
||||||
style: GoogleFonts.inter(color: Colors.red),
|
style: GoogleFonts.inter(color: Colors.red),
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
)
|
),
|
||||||
else ...[
|
] else ...[
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
value: _progress > 0 ? _progress / 100 : null,
|
value: _genProgress > 0 ? _genProgress / 100 : null,
|
||||||
color: PencilTheme.underlineGold,
|
color: PencilTheme.underlineGold,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
Text(
|
Text(
|
||||||
_status.isEmpty ? 'Processing…' : _status,
|
_genStatus.isEmpty ? 'Processing…' : _genStatus,
|
||||||
style: GoogleFonts.inter(color: PencilTheme.stone600),
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
color: PencilTheme.stone600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'$_progress%',
|
'$_genProgress%',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 28,
|
fontSize: 28,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
@ -20,6 +20,9 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
this.localCoverPath,
|
this.localCoverPath,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
|
this.showDownload = true,
|
||||||
|
this.statusLabel = '',
|
||||||
|
this.isDownloading = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MyTaskItem item;
|
final MyTaskItem item;
|
||||||
@ -27,6 +30,13 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
|
|
||||||
|
/// 仅完成态且可下载时显示 Download;否则展示 [statusLabel](与 app_client 图库一致)。
|
||||||
|
final bool showDownload;
|
||||||
|
final String statusLabel;
|
||||||
|
|
||||||
|
/// 保存到相册进行中:pill 显示加载,直至 [onDownload] 结束。
|
||||||
|
final bool isDownloading;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final url = item.resultUrl?.trim() ?? '';
|
final url = item.resultUrl?.trim() ?? '';
|
||||||
@ -128,19 +138,35 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
Positioned(
|
Positioned(
|
||||||
right: 8,
|
right: 8,
|
||||||
bottom: 8,
|
bottom: 8,
|
||||||
child: Material(
|
child: showDownload
|
||||||
|
? Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(21),
|
borderRadius: BorderRadius.circular(21),
|
||||||
side: const BorderSide(color: PencilTheme.downloadPillBorder),
|
side: const BorderSide(
|
||||||
|
color: PencilTheme.downloadPillBorder,
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
child: AbsorbPointer(
|
||||||
|
absorbing: isDownloading,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onDownload,
|
onTap: onDownload,
|
||||||
borderRadius: BorderRadius.circular(21),
|
borderRadius: BorderRadius.circular(21),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12, vertical: 6),
|
horizontal: 12,
|
||||||
child: Row(
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: isDownloading
|
||||||
|
? SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: PencilTheme.downloadPillInk,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
@ -152,13 +178,39 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 4),
|
||||||
Icon(Icons.download_rounded,
|
Icon(
|
||||||
size: 10, color: PencilTheme.downloadPillInk),
|
Icons.download_rounded,
|
||||||
|
size: 10,
|
||||||
|
color: PencilTheme.downloadPillInk,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Material(
|
||||||
|
color: Colors.black.withValues(alpha: 0.45),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(21),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
statusLabel.isNotEmpty ? statusLabel : '—',
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -4,12 +4,13 @@ import 'dart:math' show max;
|
|||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_cache_manager/flutter_cache_manager.dart';
|
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:video_player/video_player.dart';
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../core/auth/auth_service.dart';
|
import '../../core/auth/auth_service.dart';
|
||||||
|
import '../../core/open_purchase_store.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
|
import '../../core/video_file_cache.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
import '../generate/generate_screen.dart';
|
import '../generate/generate_screen.dart';
|
||||||
@ -64,7 +65,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
int _lastCategoryTabBarCount = 0;
|
int _lastCategoryTabBarCount = 0;
|
||||||
|
|
||||||
List<ExtConfigItem> _visibleExtItems(ExtConfigData? ext) =>
|
List<ExtConfigItem> _visibleExtItems(ExtConfigData? ext) =>
|
||||||
ext?.items.where((e) => e.title.trim().isNotEmpty).toList() ?? [];
|
ext?.items.where((e) => e.isUsableOnHome).toList() ?? [];
|
||||||
|
|
||||||
/// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items)。
|
/// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items)。
|
||||||
List<ExtConfigItem> _templateItemsForTab(
|
List<ExtConfigItem> _templateItemsForTab(
|
||||||
@ -385,6 +386,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
builder: (_, credits, _) {
|
builder: (_, credits, _) {
|
||||||
return PencilGlassCreditsPill(
|
return PencilGlassCreditsPill(
|
||||||
amountText: credits.toStringAsFixed(2),
|
amountText: credits.toStringAsFixed(2),
|
||||||
|
onTap: () => openPurchaseStore(context),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@ -669,6 +671,12 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
onPressed: () {
|
onPressed: () {
|
||||||
final t = template;
|
final t = template;
|
||||||
if (t == null) return;
|
if (t == null) return;
|
||||||
|
final need = _homeCostDisplay480p(t);
|
||||||
|
if (need > 0 &&
|
||||||
|
UserState.credits.value < need) {
|
||||||
|
openPurchaseStore(context);
|
||||||
|
return;
|
||||||
|
}
|
||||||
Navigator.of(context).push(
|
Navigator.of(context).push(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => GenerateScreen(
|
builder: (_) => GenerateScreen(
|
||||||
@ -734,8 +742,6 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
|
|||||||
ImageStream? _coverImageStream;
|
ImageStream? _coverImageStream;
|
||||||
ImageStreamListener? _coverImageListener;
|
ImageStreamListener? _coverImageListener;
|
||||||
|
|
||||||
static final CacheManager _videoCacheManager = DefaultCacheManager();
|
|
||||||
|
|
||||||
String get _playUrl {
|
String get _playUrl {
|
||||||
final v = widget.item.videoUrl?.trim();
|
final v = widget.item.videoUrl?.trim();
|
||||||
if (v != null && v.isNotEmpty) return v;
|
if (v != null && v.isNotEmpty) return v;
|
||||||
@ -870,14 +876,18 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
|
|||||||
}
|
}
|
||||||
_disposePlayback();
|
_disposePlayback();
|
||||||
|
|
||||||
|
final cacheKey = videoCacheKeyForUrl(playUrl);
|
||||||
VideoPlayerController? controller;
|
VideoPlayerController? controller;
|
||||||
try {
|
try {
|
||||||
if (!_forceNetworkOnly) {
|
if (!_forceNetworkOnly) {
|
||||||
final cached = await _videoCacheManager.getFileFromCache(playUrl);
|
final cached = await funymeeVideoCacheManager.getFileFromCache(cacheKey);
|
||||||
if (cached != null &&
|
if (cached != null &&
|
||||||
cached.validTill.isAfter(DateTime.now()) &&
|
cached.validTill.isAfter(DateTime.now()) &&
|
||||||
await cached.file.exists()) {
|
await cached.file.exists() &&
|
||||||
|
await videoCachedFileLooksPlayable(cached.file)) {
|
||||||
controller = VideoPlayerController.file(cached.file);
|
controller = VideoPlayerController.file(cached.file);
|
||||||
|
} else if (cached != null && await cached.file.exists()) {
|
||||||
|
await funymeeVideoCacheManager.removeFile(cacheKey);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
controller ??= VideoPlayerController.networkUrl(uri);
|
controller ??= VideoPlayerController.networkUrl(uri);
|
||||||
@ -931,7 +941,7 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_forceNetworkOnly = false;
|
_forceNetworkOnly = false;
|
||||||
unawaited(_videoCacheManager.downloadFile(playUrl));
|
unawaited(funymeeVideoCacheManager.downloadFile(playUrl, key: cacheKey));
|
||||||
setState(() {});
|
setState(() {});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -953,8 +963,8 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> {
|
|||||||
}
|
}
|
||||||
_recovering = true;
|
_recovering = true;
|
||||||
_openRetries += 1;
|
_openRetries += 1;
|
||||||
final url = _playUrl;
|
final cacheKey = videoCacheKeyForUrl(_playUrl);
|
||||||
unawaited(_videoCacheManager.removeFile(url));
|
unawaited(funymeeVideoCacheManager.removeFile(cacheKey));
|
||||||
_forceNetworkOnly = true;
|
_forceNetworkOnly = true;
|
||||||
_disposePlayback();
|
_disposePlayback();
|
||||||
setState(() {});
|
setState(() {});
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart';
|
|||||||
import 'package:package_info_plus/package_info_plus.dart';
|
import 'package:package_info_plus/package_info_plus.dart';
|
||||||
|
|
||||||
import '../../core/app_env.dart';
|
import '../../core/app_env.dart';
|
||||||
|
import '../../core/open_purchase_store.dart';
|
||||||
import '../../core/ext_config_document_urls.dart';
|
import '../../core/ext_config_document_urls.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
@ -125,12 +126,18 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
ValueListenableBuilder<int>(
|
ValueListenableBuilder<int>(
|
||||||
valueListenable: UserState.credits,
|
valueListenable: UserState.credits,
|
||||||
builder: (context, c, _) {
|
builder: (context, c, _) {
|
||||||
return Text(
|
return InkWell(
|
||||||
|
onTap: () => openPurchaseStore(context),
|
||||||
|
child: Text(
|
||||||
'Credits · ${_formatCredits(c)}',
|
'Credits · ${_formatCredits(c)}',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: PencilTheme.profileCredits,
|
color: PencilTheme.profileCredits,
|
||||||
|
decoration: TextDecoration.underline,
|
||||||
|
decorationColor: PencilTheme.profileCredits
|
||||||
|
.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
@ -5,12 +6,20 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
|
||||||
import '../../core/app_env.dart';
|
import '../../core/app_env.dart';
|
||||||
|
import '../../core/payment/google_play_order_recovery.dart';
|
||||||
import '../../core/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import '../web/app_web_view_screen.dart';
|
||||||
|
|
||||||
/// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
|
/// `ETbdo` Purchase Point:黄渐变、Bonheur 标题、积分区 + [xiabiao]、双列档位 + [credit_tag] 角标。
|
||||||
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities];Android 走 [NativeIapCoordinator.purchaseGooglePlay]。
|
/// 商品来自 [PaymentFlowCatalog.loadStoreActivities]。
|
||||||
|
/// - [ExtConfigData.allowThirdPartyPayment] 为 `true` 时:先 [PaymentApi.getPaymentMethods],再
|
||||||
|
/// [ThirdPartyCheckoutCoordinator.createOrder];**与 app_client [RechargeScreen._createOrderAndOpenUrl] 一致**:
|
||||||
|
/// - [CreatePaymentResponse.payUrl] 为空(线网常为无 `convert`)→ [NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment]
|
||||||
|
/// (仅拉起 Play + [PaymentApi.googlepay]),**不**轮询订单。
|
||||||
|
/// - [payUrl] 非空 → WebView + [ThirdPartyPaymentWatch]。
|
||||||
|
/// - 否则 Android 直接 [NativeIapCoordinator.purchaseGooglePlay];iOS 无内购接线时提示。
|
||||||
class PurchaseScreen extends StatefulWidget {
|
class PurchaseScreen extends StatefulWidget {
|
||||||
const PurchaseScreen({super.key});
|
const PurchaseScreen({super.key});
|
||||||
|
|
||||||
@ -24,6 +33,13 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
String? _loadError;
|
String? _loadError;
|
||||||
bool _paying = false;
|
bool _paying = false;
|
||||||
int? _selectedIndex;
|
int? _selectedIndex;
|
||||||
|
ThirdPartyPaymentWatch? _thirdPartyWatch;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_thirdPartyWatch?.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
@ -31,7 +47,9 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
// Defer network + setState until after first frame so the route can paint and
|
// Defer network + setState until after first frame so the route can paint and
|
||||||
// the main isolate stays responsive (avoids input ANR when opening this screen).
|
// the main isolate stays responsive (avoids input ANR when opening this screen).
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) _loadProducts(isInitial: true);
|
if (!mounted) return;
|
||||||
|
unawaited(runGooglePlayOrderRecovery());
|
||||||
|
_loadProducts(isInitial: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -59,12 +77,285 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _useThirdPartyFromExt(ExtConfigData? ext) =>
|
||||||
|
ext?.allowThirdPartyPayment == true;
|
||||||
|
|
||||||
|
Future<PaymentMethodItem?> _pickPaymentMethod(
|
||||||
|
List<PaymentMethodItem> methods,
|
||||||
|
) {
|
||||||
|
return showModalBottomSheet<PaymentMethodItem>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: Colors.white,
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||||
|
),
|
||||||
|
builder: (ctx) {
|
||||||
|
return SafeArea(
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Center(
|
||||||
|
child: Container(
|
||||||
|
width: 40,
|
||||||
|
height: 4,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: PencilTheme.genNavBackStroke,
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Payment method',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
for (var i = 0; i < methods.length; i++) ...[
|
||||||
|
if (i > 0)
|
||||||
|
Divider(
|
||||||
|
height: 1,
|
||||||
|
thickness: 1,
|
||||||
|
color: PencilTheme.genNavBackStroke,
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
minLeadingWidth: _PaymentMethodSheetIcon.slotWidth + 8,
|
||||||
|
leading: _PaymentMethodSheetIcon(
|
||||||
|
iconUrl: methods[i].icon,
|
||||||
|
),
|
||||||
|
title: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
methods[i].displayName.isNotEmpty
|
||||||
|
? methods[i].displayName
|
||||||
|
: (methods[i].paymentMethod ?? 'Payment'),
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (methods[i].recommend == true) ...[
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Recommended',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 11,
|
||||||
|
color: PencilTheme.underlineGold,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
subtitle: methods[i].bonusLabel != null
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: Text(
|
||||||
|
methods[i].bonusLabel!,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 12,
|
||||||
|
color: PencilTheme.underlineGold,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
onTap: () => Navigator.pop(ctx, methods[i]),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onBuyThirdParty(PaymentProductItem item, int index) async {
|
||||||
|
final uid = UserState.userId.value;
|
||||||
|
if (uid == null || uid.isEmpty) return;
|
||||||
|
|
||||||
|
final aidStr = item.activityId!.trim();
|
||||||
|
final aidInt = int.tryParse(aidStr);
|
||||||
|
if (aidInt == null) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Invalid activity id.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_paying = true;
|
||||||
|
_selectedIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
final methodsRes = await PaymentApi.getPaymentMethods(activityId: aidInt);
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!methodsRes.isSuccess || methodsRes.data == null) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
methodsRes.msg.isNotEmpty
|
||||||
|
? methodsRes.msg
|
||||||
|
: 'Failed to load payment methods',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final methods = methodsRes.data!.paymentMethods ?? [];
|
||||||
|
if (methods.isEmpty) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('No payment methods available.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
final picked = await _pickPaymentMethod(methods);
|
||||||
|
if (!mounted || picked == null) return;
|
||||||
|
|
||||||
|
final pm = picked.paymentMethod?.trim() ?? '';
|
||||||
|
if (pm.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(const SnackBar(content: Text('Invalid payment method.')));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState(() {
|
||||||
|
_paying = true;
|
||||||
|
_selectedIndex = index;
|
||||||
|
});
|
||||||
|
|
||||||
|
final sink = _PurchaseSink(
|
||||||
|
context: context,
|
||||||
|
onRefresh: () {
|
||||||
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_thirdPartyWatch?.dispose();
|
||||||
|
_thirdPartyWatch = null;
|
||||||
|
},
|
||||||
|
onSuccess: () {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
final outcome = await ThirdPartyCheckoutCoordinator.createOrder(
|
||||||
|
userId: uid,
|
||||||
|
activityId: aidStr,
|
||||||
|
paymentMethod: pm,
|
||||||
|
paymentType: pm,
|
||||||
|
subPaymentMethod: picked.subPaymentMethod,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
if (!outcome.isSuccess) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text(outcome.message ?? 'Create order failed')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final orderId = outcome.orderId!;
|
||||||
|
final payUrl = outcome.payUrl;
|
||||||
|
|
||||||
|
if (NativeIapCoordinator.shouldLaunchGooglePlayBillingInsteadOfWeb(
|
||||||
|
payUrl,
|
||||||
|
)) {
|
||||||
|
if (!Platform.isAndroid) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(
|
||||||
|
content: Text('Google Play billing is only available on Android.'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final storePid = item.productId!.trim();
|
||||||
|
final created = outcome.createResponse;
|
||||||
|
if (created is! CreatePaymentResponse) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Invalid payment response.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await NativeIapCoordinator.purchaseGooglePlayAfterCreatePayment(
|
||||||
|
sink: sink,
|
||||||
|
userId: uid,
|
||||||
|
storeProductId: storePid,
|
||||||
|
createResponse: created,
|
||||||
|
createPaymentApp: currentBackendAppType(),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payUrl != null && payUrl.trim().isNotEmpty) {
|
||||||
|
await Navigator.of(context).push<void>(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) =>
|
||||||
|
AppWebViewScreen(title: 'Payment', initialUrl: payUrl.trim()),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
|
||||||
|
_thirdPartyWatch?.dispose();
|
||||||
|
_thirdPartyWatch = ThirdPartyPaymentWatch(userId: uid, sink: sink);
|
||||||
|
_thirdPartyWatch!.start(orderId: orderId);
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _onBuy(PaymentProductItem item, int index) async {
|
Future<void> _onBuy(PaymentProductItem item, int index) async {
|
||||||
final uid = UserState.userId.value;
|
final uid = UserState.userId.value;
|
||||||
if (uid == null || uid.isEmpty) {
|
if (uid == null || uid.isEmpty) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(
|
||||||
const SnackBar(content: Text('Please sign in first.')),
|
context,
|
||||||
);
|
).showSnackBar(const SnackBar(content: Text('Please sign in first.')));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
final aid = item.activityId;
|
final aid = item.activityId;
|
||||||
@ -76,6 +367,12 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final ext = ExtConfigRuntime.data.value;
|
||||||
|
if (_useThirdPartyFromExt(ext)) {
|
||||||
|
await _onBuyThirdParty(item, index);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!Platform.isAndroid) {
|
if (!Platform.isAndroid) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@ -95,7 +392,12 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
final sink = _PurchaseSink(
|
final sink = _PurchaseSink(
|
||||||
context: context,
|
context: context,
|
||||||
onRefresh: () {
|
onRefresh: () {
|
||||||
if (mounted) setState(() => _paying = false);
|
if (mounted) {
|
||||||
|
setState(() {
|
||||||
|
_paying = false;
|
||||||
|
_selectedIndex = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
onSuccess: () {
|
onSuccess: () {
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
@ -201,9 +503,7 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
|
|||||||
? Center(
|
? Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'No products available',
|
'No products available',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(color: PencilTheme.stone600),
|
||||||
color: PencilTheme.stone600,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: _ProductGrid(
|
: _ProductGrid(
|
||||||
@ -329,10 +629,7 @@ class _CreditHeaderSection extends StatelessWidget {
|
|||||||
gradient: const LinearGradient(
|
gradient: const LinearGradient(
|
||||||
begin: Alignment.topCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [Color(0xFFFAE238), Color(0xFFF5BE5D)],
|
||||||
Color(0xFFFAE238),
|
|
||||||
Color(0xFFF5BE5D),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
border: Border.all(color: Colors.white, width: 1.5),
|
||||||
@ -449,6 +746,22 @@ class _ProductCard extends StatelessWidget {
|
|||||||
|
|
||||||
static final _money = RegExp(r'[\d.]+');
|
static final _money = RegExp(r'[\d.]+');
|
||||||
|
|
||||||
|
/// [PaymentProductItem.bonus] + [PaymentProductItem.bonusCredits](换皮线网 `contrast` + `saturation`)。
|
||||||
|
static String? _bonusDisplayLine({
|
||||||
|
required int base,
|
||||||
|
required int gift,
|
||||||
|
required int total,
|
||||||
|
}) {
|
||||||
|
if (total <= 0) return null;
|
||||||
|
if (gift > 0 && base > 0) {
|
||||||
|
return '+$total bonus (incl. $gift gift)';
|
||||||
|
}
|
||||||
|
if (gift > 0) {
|
||||||
|
return '+$gift gift credits';
|
||||||
|
}
|
||||||
|
return '+$base bonus';
|
||||||
|
}
|
||||||
|
|
||||||
static int? _discountPercent(String? actual, String? origin) {
|
static int? _discountPercent(String? actual, String? origin) {
|
||||||
final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? '');
|
final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? '');
|
||||||
final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? '');
|
final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? '');
|
||||||
@ -466,7 +779,14 @@ class _ProductCard extends StatelessWidget {
|
|||||||
: 'Credits:—';
|
: 'Credits:—';
|
||||||
final actual = item.actualAmount ?? '—';
|
final actual = item.actualAmount ?? '—';
|
||||||
final origin = item.originAmount;
|
final origin = item.originAmount;
|
||||||
final bonus = item.bonus;
|
final bonusBase = item.bonus ?? 0;
|
||||||
|
final bonusGift = item.bonusCredits ?? 0;
|
||||||
|
final bonusTotal = bonusBase + bonusGift;
|
||||||
|
final bonusLine = _bonusDisplayLine(
|
||||||
|
base: bonusBase,
|
||||||
|
gift: bonusGift,
|
||||||
|
total: bonusTotal,
|
||||||
|
);
|
||||||
final pct = _discountPercent(item.actualAmount, item.originAmount);
|
final pct = _discountPercent(item.actualAmount, item.originAmount);
|
||||||
|
|
||||||
return Material(
|
return Material(
|
||||||
@ -525,12 +845,13 @@ class _ProductCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
if (bonus != null && bonus > 0) ...[
|
if (bonusLine != null) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: Text(
|
child: Text(
|
||||||
'+$bonus Bonus',
|
bonusLine,
|
||||||
|
textAlign: TextAlign.right,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 12,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
@ -545,7 +866,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
if (pct != null && pct > 0)
|
if (pct != null && pct > 0)
|
||||||
Positioned(
|
Positioned(
|
||||||
right: -4,
|
right: -4,
|
||||||
top: -8,
|
top: -18,
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 60,
|
width: 60,
|
||||||
height: 33,
|
height: 33,
|
||||||
@ -555,10 +876,11 @@ class _ProductCard extends StatelessWidget {
|
|||||||
Image.asset(
|
Image.asset(
|
||||||
'assets/images/credit_tag.png',
|
'assets/images/credit_tag.png',
|
||||||
fit: BoxFit.fill,
|
fit: BoxFit.fill,
|
||||||
|
width: 80,
|
||||||
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 4),
|
padding: const EdgeInsets.only(top: 4, right: 10),
|
||||||
child: Text(
|
child: Text(
|
||||||
'$pct% Off',
|
'$pct% Off',
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@ -593,3 +915,37 @@ class _ProductCard extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 支付方式图标:与 app_client [_PaymentIcon] 一致用 [BoxFit.contain] 完整显示、不变形;
|
||||||
|
/// 使用横向矩形槽位以贴近常见支付 Logo 比例(相对 40×40 裁切更自然)。
|
||||||
|
class _PaymentMethodSheetIcon extends StatelessWidget {
|
||||||
|
const _PaymentMethodSheetIcon({this.iconUrl});
|
||||||
|
|
||||||
|
final String? iconUrl;
|
||||||
|
|
||||||
|
/// 横向略宽,给长方形原图留足空间(高度与 app_client 40 一致)。
|
||||||
|
static const double slotWidth = 56;
|
||||||
|
static const double slotHeight = 40;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final url = iconUrl?.trim();
|
||||||
|
final fallback = Icon(
|
||||||
|
Icons.payment_outlined,
|
||||||
|
size: 24,
|
||||||
|
color: PencilTheme.underlineGold,
|
||||||
|
);
|
||||||
|
return SizedBox(
|
||||||
|
width: slotWidth,
|
||||||
|
height: slotHeight,
|
||||||
|
child: url != null && url.isNotEmpty
|
||||||
|
? Image.network(
|
||||||
|
url,
|
||||||
|
fit: BoxFit.contain,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
errorBuilder: (_, _, _) => Center(child: fallback),
|
||||||
|
)
|
||||||
|
: Center(child: fallback),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
59
lib/features/report/report_feedback_upload.dart
Normal file
59
lib/features/report/report_feedback_upload.dart
Normal file
@ -0,0 +1,59 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
|
||||||
|
/// PUT 到 [FeedbackApi.getUploadPresignedUrl] 返回的地址,返回服务端 [FeedbackUploadPresignedUrlResponse.filePath] 供 [FeedbackApi.submit] 的 `fileUrls`。
|
||||||
|
Future<String> uploadFeedbackAttachment(File file) async {
|
||||||
|
final name = file.path.split('/').last;
|
||||||
|
final presignedRes = await FeedbackApi.getUploadPresignedUrl(fileName: name);
|
||||||
|
if (!presignedRes.isSuccess || presignedRes.data == null) {
|
||||||
|
throw StateError(
|
||||||
|
presignedRes.msg.isNotEmpty
|
||||||
|
? presignedRes.msg
|
||||||
|
: 'Could not get upload URL',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final p = presignedRes.data!;
|
||||||
|
final uploadUrl = p.uploadUrl;
|
||||||
|
final filePath = p.filePath;
|
||||||
|
if (uploadUrl == null ||
|
||||||
|
uploadUrl.isEmpty ||
|
||||||
|
filePath == null ||
|
||||||
|
filePath.isEmpty) {
|
||||||
|
throw StateError('Invalid upload URL response');
|
||||||
|
}
|
||||||
|
|
||||||
|
final bytes = await file.readAsBytes();
|
||||||
|
final contentType = _mimeForPath(file.path);
|
||||||
|
final headers = <String, String>{'Content-Type': contentType};
|
||||||
|
final extra = p.putHeaders;
|
||||||
|
if (extra != null) {
|
||||||
|
for (final e in extra.entries) {
|
||||||
|
final k = e.key.trim();
|
||||||
|
if (k.isEmpty) continue;
|
||||||
|
headers[k] = e.value.toString();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!headers.containsKey('Content-Type')) {
|
||||||
|
headers['Content-Type'] = contentType;
|
||||||
|
}
|
||||||
|
|
||||||
|
final r = await http.put(
|
||||||
|
Uri.parse(uploadUrl),
|
||||||
|
headers: headers,
|
||||||
|
body: bytes,
|
||||||
|
);
|
||||||
|
if (r.statusCode < 200 || r.statusCode >= 300) {
|
||||||
|
throw StateError('Upload failed (${r.statusCode})');
|
||||||
|
}
|
||||||
|
return filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _mimeForPath(String path) {
|
||||||
|
final lower = path.toLowerCase();
|
||||||
|
if (lower.endsWith('.png')) return 'image/png';
|
||||||
|
if (lower.endsWith('.gif')) return 'image/gif';
|
||||||
|
if (lower.endsWith('.webp')) return 'image/webp';
|
||||||
|
return 'image/jpeg';
|
||||||
|
}
|
||||||
@ -1,9 +1,18 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
import '../../widgets/pencil_chrome.dart';
|
||||||
|
import 'report_feedback_upload.dart';
|
||||||
|
|
||||||
|
/// Report / feedback screen.
|
||||||
|
///
|
||||||
|
/// API: [FeedbackApi.getUploadPresignedUrl] → PUT → [FeedbackApi.submit] (`fileUrls`, `content`, `contentType`), per FunyMee client guide — feedback section.
|
||||||
|
/// Design reference: `desgin/funymee_home.pen` node `Y9WlO` (Pencil); English copy only; **one** optional image.
|
||||||
class ReportScreen extends StatefulWidget {
|
class ReportScreen extends StatefulWidget {
|
||||||
const ReportScreen({super.key, required this.taskId});
|
const ReportScreen({super.key, required this.taskId});
|
||||||
|
|
||||||
@ -15,6 +24,12 @@ class ReportScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _ReportScreenState extends State<ReportScreen> {
|
class _ReportScreenState extends State<ReportScreen> {
|
||||||
final _controller = TextEditingController();
|
final _controller = TextEditingController();
|
||||||
|
final _picker = ImagePicker();
|
||||||
|
File? _imageFile;
|
||||||
|
bool _submitting = false;
|
||||||
|
|
||||||
|
/// Logical `contentType` for [FeedbackApi.submit] (maps via `fieldMapping` when sent).
|
||||||
|
static const _feedbackContentType = 'report';
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
@ -22,6 +37,70 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _pickImage() async {
|
||||||
|
if (_submitting) return;
|
||||||
|
final x = await _picker.pickImage(
|
||||||
|
source: ImageSource.gallery,
|
||||||
|
imageQuality: 85,
|
||||||
|
);
|
||||||
|
if (x == null || !mounted) return;
|
||||||
|
setState(() => _imageFile = File(x.path));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _clearImage() {
|
||||||
|
if (_submitting) return;
|
||||||
|
setState(() => _imageFile = null);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _submit() async {
|
||||||
|
final text = _controller.text.trim();
|
||||||
|
if (text.isEmpty) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
const SnackBar(content: Text('Please describe the issue.')),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setState(() => _submitting = true);
|
||||||
|
try {
|
||||||
|
final urls = <String>[];
|
||||||
|
if (_imageFile != null) {
|
||||||
|
final path = await uploadFeedbackAttachment(_imageFile!);
|
||||||
|
urls.add(path);
|
||||||
|
}
|
||||||
|
final content = 'Task ID: ${widget.taskId}\n\n$text';
|
||||||
|
final res = await FeedbackApi.submit(
|
||||||
|
fileUrls: urls,
|
||||||
|
content: content,
|
||||||
|
contentType: _feedbackContentType,
|
||||||
|
);
|
||||||
|
if (!mounted) return;
|
||||||
|
if (!res.isSuccess) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
res.msg.isNotEmpty ? res.msg : 'Submit failed (code ${res.code})',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Thank you. Your report was sent.',
|
||||||
|
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('$e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _submitting = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
@ -32,6 +111,7 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Column(
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||||||
@ -40,7 +120,10 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
PencilRoundBackButton(
|
PencilRoundBackButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () {
|
||||||
|
if (_submitting) return;
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
},
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
@ -61,52 +144,189 @@ class _ReportScreenState extends State<ReportScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Padding(
|
child: ListView(
|
||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'Task: ${widget.taskId}',
|
'Tell us what went wrong.',
|
||||||
style: GoogleFonts.inter(color: PencilTheme.stone600),
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
height: 1.4,
|
||||||
|
color: PencilTheme.stone600,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
|
Text(
|
||||||
|
'Related task',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
SelectableText(
|
||||||
|
widget.taskId,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
color: PencilTheme.stone600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Description',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
TextField(
|
TextField(
|
||||||
controller: _controller,
|
controller: _controller,
|
||||||
maxLines: 5,
|
maxLines: 6,
|
||||||
|
readOnly: _submitting,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.45,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
cursorColor: PencilTheme.underlineGold,
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Describe the issue…',
|
hintText: 'Describe the issue in detail…',
|
||||||
|
hintStyle: GoogleFonts.inter(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.45,
|
||||||
|
color: PencilTheme.stone700,
|
||||||
|
),
|
||||||
filled: true,
|
filled: true,
|
||||||
fillColor: Colors.white,
|
fillColor: Colors.white,
|
||||||
border: OutlineInputBorder(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 14,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: PencilTheme.stone600.withValues(alpha: 0.55),
|
||||||
|
width: 1,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
borderSide: const BorderSide(
|
borderSide: const BorderSide(
|
||||||
color: PencilTheme.genNavBackStroke),
|
color: PencilTheme.underlineGold,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
disabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(
|
||||||
|
color: PencilTheme.stone600.withValues(alpha: 0.4),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
Text(
|
||||||
|
'Screenshot (optional, one image only)',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: _submitting ? null : _pickImage,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: Ink(
|
||||||
|
width: 112,
|
||||||
|
height: 112,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
border: Border.all(
|
||||||
|
color: PencilTheme.genNavBackStroke,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: _imageFile == null
|
||||||
|
? Icon(
|
||||||
|
Icons.add_photo_alternate_outlined,
|
||||||
|
size: 36,
|
||||||
|
color: PencilTheme.stone600.withValues(
|
||||||
|
alpha: 0.7,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
child: Image.file(
|
||||||
|
_imageFile!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_imageFile != null) ...[
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 4),
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _submitting ? null : _clearImage,
|
||||||
|
child: Text(
|
||||||
|
'Remove',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.stone700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 28),
|
||||||
FilledButton(
|
FilledButton(
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: PencilTheme.underlineGold,
|
backgroundColor: PencilTheme.underlineGold,
|
||||||
foregroundColor: Colors.white,
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size.fromHeight(52),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
if (_submitting) return;
|
||||||
const SnackBar(
|
_submit();
|
||||||
content: Text(
|
|
||||||
'Submit wired in FeedbackApi (see §13).',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Navigator.of(context).pop();
|
|
||||||
},
|
},
|
||||||
child: const Text('Submit'),
|
child: _submitting
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Submit',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,5 +1,9 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../../core/auth/auth_service.dart';
|
||||||
|
import '../../core/payment/google_play_order_recovery.dart';
|
||||||
import '../history/history_screen.dart';
|
import '../history/history_screen.dart';
|
||||||
import '../home/home_screen.dart';
|
import '../home/home_screen.dart';
|
||||||
import '../profile/profile_screen.dart';
|
import '../profile/profile_screen.dart';
|
||||||
@ -14,6 +18,23 @@ class MainScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _MainScreenState extends State<MainScreen> {
|
class _MainScreenState extends State<MainScreen> {
|
||||||
int _index = 0;
|
int _index = 0;
|
||||||
|
VoidCallback? _cancelLoginRecoveryHook;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_cancelLoginRecoveryHook = AuthService.whenLoginSucceeded(
|
||||||
|
onReady: () {
|
||||||
|
unawaited(runGooglePlayOrderRecovery());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cancelLoginRecoveryHook?.call();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
|||||||
@ -47,21 +47,15 @@ class PencilGlassCreditsPill extends StatelessWidget {
|
|||||||
const PencilGlassCreditsPill({
|
const PencilGlassCreditsPill({
|
||||||
super.key,
|
super.key,
|
||||||
required this.amountText,
|
required this.amountText,
|
||||||
|
this.onTap,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String amountText;
|
final String amountText;
|
||||||
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClipRRect(
|
final row = Row(
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
|
||||||
child: Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
|
||||||
color: PencilTheme.homeGlassFill,
|
|
||||||
height: 35,
|
|
||||||
child: Row(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
const Icon(Icons.diamond_rounded,
|
const Icon(Icons.diamond_rounded,
|
||||||
@ -76,56 +70,89 @@ class PencilGlassCreditsPill extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
);
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: BackdropFilter(
|
||||||
|
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
|
||||||
|
child: onTap != null
|
||||||
|
? Material(
|
||||||
|
color: PencilTheme.homeGlassFill,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
height: 35,
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: row,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
|
color: PencilTheme.homeGlassFill,
|
||||||
|
height: 35,
|
||||||
|
child: row,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// bi8Au Create Now:宽 186 高 40,pill blur 28。
|
/// bi8Au Create Now:与 `desgin/funymee_home.pen` [aHMps] 一致 — 金渐变、白描边、投影。
|
||||||
class PencilCreateNowButton extends StatelessWidget {
|
class PencilCreateNowButton extends StatelessWidget {
|
||||||
const PencilCreateNowButton({super.key, required this.onPressed});
|
const PencilCreateNowButton({super.key, required this.onPressed});
|
||||||
|
|
||||||
final VoidCallback onPressed;
|
final VoidCallback onPressed;
|
||||||
|
|
||||||
|
static const double _w = 212;
|
||||||
|
static const double _h = 50;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ClipRRect(
|
return Material(
|
||||||
borderRadius: BorderRadius.circular(999),
|
color: Colors.transparent,
|
||||||
child: BackdropFilter(
|
|
||||||
filter: ImageFilter.blur(sigmaX: 28, sigmaY: 28),
|
|
||||||
child: Material(
|
|
||||||
color: PencilTheme.createPillFill,
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onPressed,
|
onTap: onPressed,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Ink(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
gradient: const LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Color(0xFFFFFDE7),
|
||||||
|
Color(0xFFFDE047),
|
||||||
|
Color(0xFFF59E0B),
|
||||||
|
],
|
||||||
|
stops: [0.0, 0.42, 1.0],
|
||||||
|
),
|
||||||
|
border: Border.all(
|
||||||
|
color: Color(0xD9FFFFFF),
|
||||||
|
width: 2,
|
||||||
|
),
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x52B45309),
|
||||||
|
offset: Offset(0, 10),
|
||||||
|
blurRadius: 28,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
width: 186,
|
width: _w,
|
||||||
height: 40,
|
height: _h,
|
||||||
child: Row(
|
child: Center(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
child: Text(
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 25,
|
|
||||||
height: 25,
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: PencilTheme.createPlusDisc,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.add, size: 14, color: Colors.black),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Text(
|
|
||||||
'Create Now',
|
'Create Now',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: PencilTheme.homeTextPrimary,
|
color: PencilTheme.stone900,
|
||||||
letterSpacing: 0.3,
|
letterSpacing: 0.4,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
302
lib/widgets/resilient_network_video.dart
Normal file
302
lib/widgets/resilient_network_video.dart
Normal file
@ -0,0 +1,302 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:video_player/video_player.dart';
|
||||||
|
|
||||||
|
import '../core/video_file_cache.dart';
|
||||||
|
|
||||||
|
/// 网络视频播放:优先 **稳定 key** 的磁盘缓存(忽略签名 query),遇 ExoPlayer **416**、
|
||||||
|
/// [UnrecognizedInputFormatException](坏/HTML 缓存)、[VideoPlayerController] 报错时清缓存并重试
|
||||||
|
/// (与 [HomeScreen] `_HomeItemVideoBackground` 对齐)。
|
||||||
|
class ResilientNetworkVideoCover extends StatefulWidget {
|
||||||
|
const ResilientNetworkVideoCover({
|
||||||
|
super.key,
|
||||||
|
required this.url,
|
||||||
|
this.isActive = true,
|
||||||
|
this.looping = true,
|
||||||
|
this.volume = 1.0,
|
||||||
|
this.loadingWidget,
|
||||||
|
this.failedWidget,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String url;
|
||||||
|
final bool isActive;
|
||||||
|
final bool looping;
|
||||||
|
final double volume;
|
||||||
|
final Widget? loadingWidget;
|
||||||
|
final Widget? failedWidget;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<ResilientNetworkVideoCover> createState() =>
|
||||||
|
_ResilientNetworkVideoCoverState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResilientNetworkVideoCoverState extends State<ResilientNetworkVideoCover> {
|
||||||
|
VideoPlayerController? _controller;
|
||||||
|
bool _failed = false;
|
||||||
|
|
||||||
|
static const int _maxOpenRetries = 6;
|
||||||
|
int _openRetries = 0;
|
||||||
|
bool _forceNetworkOnly = false;
|
||||||
|
Timer? _retryTimer;
|
||||||
|
bool _recovering = false;
|
||||||
|
|
||||||
|
String get _playUrl => widget.url.trim();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.isActive) {
|
||||||
|
unawaited(_startPlaybackAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(ResilientNetworkVideoCover oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (oldWidget.isActive != widget.isActive) {
|
||||||
|
if (!widget.isActive) {
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
_retryTimer = null;
|
||||||
|
_recovering = false;
|
||||||
|
_openRetries = 0;
|
||||||
|
_disposePlayback();
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_failed = false;
|
||||||
|
_forceNetworkOnly = false;
|
||||||
|
unawaited(_startPlaybackAsync());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (oldWidget.url != widget.url) {
|
||||||
|
_openRetries = 0;
|
||||||
|
_failed = false;
|
||||||
|
_recovering = false;
|
||||||
|
_forceNetworkOnly = false;
|
||||||
|
_disposePlayback();
|
||||||
|
if (widget.isActive) {
|
||||||
|
unawaited(_startPlaybackAsync());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
_disposePlayback();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
static Future<void> _pauseThenDispose(VideoPlayerController c) async {
|
||||||
|
try {
|
||||||
|
await c.pause();
|
||||||
|
} catch (_) {}
|
||||||
|
try {
|
||||||
|
await c.dispose();
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _disposePlayback() {
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
_retryTimer = null;
|
||||||
|
final c = _controller;
|
||||||
|
if (c != null) {
|
||||||
|
c.removeListener(_onVideoValueChanged);
|
||||||
|
_controller = null;
|
||||||
|
unawaited(_pauseThenDispose(c));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _startPlaybackAsync() async {
|
||||||
|
if (!widget.isActive) return;
|
||||||
|
final playUrl = _playUrl;
|
||||||
|
final uri = Uri.tryParse(playUrl);
|
||||||
|
if (uri == null || !(uri.isScheme('http') || uri.isScheme('https'))) {
|
||||||
|
if (mounted) setState(() => _failed = true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_disposePlayback();
|
||||||
|
|
||||||
|
final cacheKey = videoCacheKeyForUrl(playUrl);
|
||||||
|
VideoPlayerController? controller;
|
||||||
|
try {
|
||||||
|
if (!_forceNetworkOnly) {
|
||||||
|
final cached = await funymeeVideoCacheManager.getFileFromCache(cacheKey);
|
||||||
|
if (cached != null &&
|
||||||
|
cached.validTill.isAfter(DateTime.now()) &&
|
||||||
|
await cached.file.exists() &&
|
||||||
|
await videoCachedFileLooksPlayable(cached.file)) {
|
||||||
|
controller = VideoPlayerController.file(cached.file);
|
||||||
|
} else if (cached != null && await cached.file.exists()) {
|
||||||
|
await funymeeVideoCacheManager.removeFile(cacheKey);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
controller ??= VideoPlayerController.networkUrl(uri);
|
||||||
|
} catch (_) {
|
||||||
|
controller = VideoPlayerController.networkUrl(uri);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted || playUrl != _playUrl || !widget.isActive) {
|
||||||
|
unawaited(_pauseThenDispose(controller));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
controller
|
||||||
|
..setLooping(widget.looping)
|
||||||
|
..setVolume(widget.volume);
|
||||||
|
controller.addListener(_onVideoValueChanged);
|
||||||
|
_controller = controller;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await controller.initialize().timeout(
|
||||||
|
const Duration(seconds: 20),
|
||||||
|
onTimeout: () =>
|
||||||
|
throw TimeoutException('video init', const Duration(seconds: 20)),
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _scheduleRecoverFromError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted ||
|
||||||
|
_controller != controller ||
|
||||||
|
playUrl != _playUrl ||
|
||||||
|
!widget.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (controller.value.hasError) {
|
||||||
|
if (mounted) _scheduleRecoverFromError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_openRetries = 0;
|
||||||
|
try {
|
||||||
|
await controller.play();
|
||||||
|
} catch (_) {
|
||||||
|
if (mounted) _scheduleRecoverFromError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted ||
|
||||||
|
_controller != controller ||
|
||||||
|
playUrl != _playUrl ||
|
||||||
|
!widget.isActive) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (controller.value.hasError) {
|
||||||
|
if (mounted) _scheduleRecoverFromError();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_forceNetworkOnly = false;
|
||||||
|
unawaited(funymeeVideoCacheManager.downloadFile(playUrl, key: cacheKey));
|
||||||
|
setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onVideoValueChanged() {
|
||||||
|
final c = _controller;
|
||||||
|
if (c == null || !mounted || _recovering) return;
|
||||||
|
if (!c.value.hasError) return;
|
||||||
|
c.removeListener(_onVideoValueChanged);
|
||||||
|
_scheduleRecoverFromError();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _scheduleRecoverFromError() {
|
||||||
|
if (!mounted || _failed || !widget.isActive) return;
|
||||||
|
if (_recovering) return;
|
||||||
|
if (_openRetries >= _maxOpenRetries) {
|
||||||
|
setState(() => _failed = true);
|
||||||
|
_disposePlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recovering = true;
|
||||||
|
_openRetries += 1;
|
||||||
|
final cacheKey = videoCacheKeyForUrl(_playUrl);
|
||||||
|
unawaited(funymeeVideoCacheManager.removeFile(cacheKey));
|
||||||
|
_forceNetworkOnly = true;
|
||||||
|
_disposePlayback();
|
||||||
|
setState(() {});
|
||||||
|
|
||||||
|
_retryTimer?.cancel();
|
||||||
|
_retryTimer = Timer(const Duration(milliseconds: 220), () {
|
||||||
|
_retryTimer = null;
|
||||||
|
if (!mounted) return;
|
||||||
|
_recovering = false;
|
||||||
|
unawaited(_startPlaybackAsync());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
if (!widget.isActive) {
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: widget.loadingWidget ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_failed) {
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: widget.failedWidget ?? const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final c = _controller;
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
if (c == null || !c.value.isInitialized) {
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: widget.loadingWidget ??
|
||||||
|
const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final cw = constraints.maxWidth;
|
||||||
|
final ch = constraints.maxHeight;
|
||||||
|
final w = c.value.size.width;
|
||||||
|
final h = c.value.size.height;
|
||||||
|
if (w <= 0 ||
|
||||||
|
h <= 0 ||
|
||||||
|
!cw.isFinite ||
|
||||||
|
!ch.isFinite ||
|
||||||
|
cw <= 0 ||
|
||||||
|
ch <= 0) {
|
||||||
|
return SizedBox.expand(
|
||||||
|
child: widget.loadingWidget ??
|
||||||
|
const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white54),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Positioned.fill(
|
||||||
|
child: ClipRect(
|
||||||
|
child: SizedBox(
|
||||||
|
width: cw,
|
||||||
|
height: ch,
|
||||||
|
child: FittedBox(
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
child: SizedBox(
|
||||||
|
width: w,
|
||||||
|
height: h,
|
||||||
|
child: VideoPlayer(c),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user