diff --git a/.metadata b/.metadata index 89db5de..df13aa7 100644 --- a/.metadata +++ b/.metadata @@ -18,21 +18,6 @@ migration: - platform: android create_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 diff --git a/android/app/src/main/kotlin/com/funymeeai/funymee_ai/MainActivity.kt b/android/app/src/main/kotlin/com/funymeeai/funymee_ai/MainActivity.kt new file mode 100644 index 0000000..4d582ad --- /dev/null +++ b/android/app/src/main/kotlin/com/funymeeai/funymee_ai/MainActivity.kt @@ -0,0 +1,5 @@ +package com.funymeeai.funymee_ai + +import io.flutter.embedding.android.FlutterActivity + +class MainActivity : FlutterActivity() diff --git a/assets/skin_config.json b/assets/skin_config.json index 2c2d794..6fff429 100644 --- a/assets/skin_config.json +++ b/assets/skin_config.json @@ -98,6 +98,21 @@ "detail": ["detail"], "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": { "go_run": false, "screen": false, diff --git a/desgin/funymee_home.pen b/desgin/funymee_home.pen index f39c549..5b17c1a 100644 --- a/desgin/funymee_home.pen +++ b/desgin/funymee_home.pen @@ -263,7 +263,7 @@ "id": "6WRtN", "name": "createWrap", "width": "fill_container", - "height": 71, + "height": 80, "fill": "#00000000", "justifyContent": "center", "alignItems": "center", @@ -272,18 +272,51 @@ "type": "frame", "id": "aHMps", "name": "createBtn", - "width": 186, - "height": 40, - "fill": "#FFFFFF4D", - "cornerRadius": 999, - "effect": { - "type": "background_blur", - "radius": 28 + "width": 212, + "height": 50, + "fill": { + "type": "gradient", + "gradientType": "linear", + "enabled": true, + "rotation": 90, + "size": { + "height": 1 + }, + "colors": [ + { + "color": "#FFFDE7", + "position": 0 + }, + { + "color": "#FDE047", + "position": 0.42 + }, + { + "color": "#F59E0B", + "position": 1 + } + ] }, - "gap": 14, + "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": [ - 18, - 36 + 14, + 28 ], "justifyContent": "center", "alignItems": "center", @@ -292,10 +325,25 @@ "type": "frame", "id": "TAocZ", "name": "plusCirc", - "width": 25, - "height": 25, - "fill": "#FFD60A", - "cornerRadius": 18, + "width": 28, + "height": 28, + "fill": "#FFFFFF", + "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", "alignItems": "center", "children": [ @@ -303,11 +351,11 @@ "type": "icon_font", "id": "9PFVT", "name": "plusIc", - "width": 12, - "height": 12, + "width": 14, + "height": 14, "iconFontName": "plus", "iconFontFamily": "lucide", - "fill": "#000000ff" + "fill": "#B45309" } ] }, @@ -315,12 +363,12 @@ "type": "text", "id": "rR4OC", "name": "ctaTxt", - "fill": "#FFFFFF", + "fill": "#1C1917", "content": "Create Now", "fontFamily": "Inter", "fontSize": 18, - "fontWeight": "700", - "letterSpacing": 0.3 + "fontWeight": "800", + "letterSpacing": 0.4 } ] } diff --git a/lib/core/ext_config_document_urls.dart b/lib/core/ext_config_document_urls.dart index 1070323..1a40e3f 100644 --- a/lib/core/ext_config_document_urls.dart +++ b/lib/core/ext_config_document_urls.dart @@ -39,6 +39,7 @@ abstract final class ExtConfigDocumentUrls { return ExtConfigData.fromJson( Map.from(raw), schema: schema, + fieldMapping: cfg.fieldMapping, ); } catch (_) { return null; diff --git a/lib/core/open_purchase_store.dart b/lib/core/open_purchase_store.dart new file mode 100644 index 0000000..be2210e --- /dev/null +++ b/lib/core/open_purchase_store.dart @@ -0,0 +1,10 @@ +import 'package:flutter/material.dart'; + +import '../features/purchase/purchase_screen.dart'; + +/// 打开积分商店(充值页)。 +void openPurchaseStore(BuildContext context) { + Navigator.of(context).push( + MaterialPageRoute(builder: (_) => const PurchaseScreen()), + ); +} diff --git a/lib/core/payment/google_play_order_recovery.dart b/lib/core/payment/google_play_order_recovery.dart new file mode 100644 index 0000000..a8ea3b9 --- /dev/null +++ b/lib/core/payment/google_play_order_recovery.dart @@ -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 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!); + }, + ); + } +} diff --git a/lib/core/video_file_cache.dart b/lib/core/video_file_cache.dart new file mode 100644 index 0000000..5f52822 --- /dev/null +++ b/lib/core/video_file_cache.dart @@ -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 videoCachedFileLooksPlayable(File f) async { + try { + final len = await f.length(); + if (len < 512) return false; + late List 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('= 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; + } +} diff --git a/lib/design/pencil_theme.dart b/lib/design/pencil_theme.dart index fd0a01d..f0a4104 100644 --- a/lib/design/pencil_theme.dart +++ b/lib/design/pencil_theme.dart @@ -9,7 +9,7 @@ abstract final class PencilTheme { static const Color homeTabDivider = Color(0x66FFFFFF); static const Color gemYellow = Color(0xFFFFD60A); - /// Create Now 磨砂 pill + /// 旧版 Create Now 磨砂底(当前 UI 已改用金渐变 [PencilCreateNowButton];保留供参考)。 static const Color createPillFill = Color(0x4DFFFFFF); static const Color createPlusDisc = Color(0xFFFFD60A); diff --git a/lib/features/generate/generate_result_screen.dart b/lib/features/generate/generate_result_screen.dart index 085f361..bb3de51 100644 --- a/lib/features/generate/generate_result_screen.dart +++ b/lib/features/generate/generate_result_screen.dart @@ -6,9 +6,9 @@ import 'package:flutter/services.dart'; import 'package:gal/gal.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:http/http.dart' as http; -import 'package:video_player/video_player.dart'; import '../../design/pencil_theme.dart'; +import '../../widgets/resilient_network_video.dart'; import '../report/report_screen.dart'; /// 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 { - VideoPlayerController? _video; - bool _videoInitFailed = false; bool _saving = false; bool get _hasUrl { @@ -43,43 +41,6 @@ class _GenerateResultScreenState extends State { bool get _isVideo => _urlLooksLikeVideo(widget.resultUrl); - @override - void initState() { - super.initState(); - if (_hasUrl && _isVideo) { - _initVideo(); - } - } - - Future _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 _saveToGallery() async { if (!_hasUrl || _saving) return; setState(() => _saving = true); @@ -254,6 +215,32 @@ class _GenerateResultScreenState extends State { ), ), ), + 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( alignment: Alignment.bottomCenter, child: _ResultBottomBar( @@ -290,8 +277,17 @@ class _GenerateResultScreenState extends State { ); } if (_isVideo) { - if (_videoInitFailed) { - return ColoredBox( + final u = widget.resultUrl.trim(); + return ResilientNetworkVideoCover( + key: ValueKey(u), + url: u, + loadingWidget: const ColoredBox( + color: Colors.black, + child: Center( + child: CircularProgressIndicator(color: Colors.white54), + ), + ), + failedWidget: ColoredBox( color: PencilTheme.stone900, child: Center( child: Icon( @@ -300,69 +296,7 @@ class _GenerateResultScreenState extends State { 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( diff --git a/lib/features/generate/generate_screen.dart b/lib/features/generate/generate_screen.dart index a6fa428..993eb5b 100644 --- a/lib/features/generate/generate_screen.dart +++ b/lib/features/generate/generate_screen.dart @@ -7,15 +7,17 @@ import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:image_picker/image_picker.dart'; -import 'package:video_player/video_player.dart'; import '../../core/app_env.dart'; +import '../../core/open_purchase_store.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.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 槽位 +「=」+ 右侧效果图;分辨率在「开始生成」上方横排。 +/// 提交后在同页轮询进度,完成后 [pushReplacement] 至 [GenerateResultScreen]。 /// [ExtConfigItem.imgNeed] == 2 时需两张图并上传;其余(含视频类模板)只传一张,请在配置里将视频项 `img_need` 设为 1。 /// [template] 来自首页选中的 [ExtConfigItem]。 class GenerateScreen extends StatefulWidget { @@ -31,12 +33,20 @@ class _GenerateScreenState extends State { final _picker = ImagePicker(); File? _picked; File? _picked2; + /// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 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 _slotH = 108; @@ -70,22 +80,69 @@ class _GenerateScreenState extends State { 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 { - final p = widget.template?.params?.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; } + /// 仅当模板同时提供 [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 _pickSlot(int slot) async { + if (_generating) return; if (!mounted) return; final source = await _showPickImageSourceSheet(context); if (source == null || !mounted) return; - final x = await _picker.pickImage( - source: source, - imageQuality: 92, - ); + final x = await _picker.pickImage(source: source, imageQuality: 92); if (x == null || !mounted) return; setState(() { if (slot == 0) { @@ -180,9 +237,14 @@ class _GenerateScreenState extends State { Future _start() async { final uid = UserState.userId.value; if (uid == null || uid.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please sign in first.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Please sign in first.'))); + return; + } + final need = _estimatedCost; + if (UserState.credits.value < need) { + openPurchaseStore(context); return; } if (_needTwoImages) { @@ -202,7 +264,11 @@ class _GenerateScreenState extends State { } } - setState(() => _busy = true); + setState(() { + _generating = true; + _genError = null; + }); + var pollStarted = false; try { final ImagePresignedUploadCreateTaskResult result; if (_needTwoImages) { @@ -252,22 +318,94 @@ class _GenerateScreenState extends State { ); if (!mounted) return; - Navigator.of(context).pushReplacement( - MaterialPageRoute( - builder: (_) => GenerateProgressScreen( - taskId: taskId, - localPreviewPath: result.fileUsedForUpload.path, - ), - ), - ); + pollStarted = true; + _startProgressPoll(taskId); } catch (e) { if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text('$e')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text('$e'))); } } 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( + 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 { return u != null && _urlLooksLikeVideo(u); } - Future _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 _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 void dispose() { - _disposePreviewVideo(); + _pollHandle?.cancel(); super.dispose(); } @@ -372,6 +451,9 @@ class _GenerateScreenState extends State { return c > 0 ? c : fallback; } + /// 进度条已出现(轮询开始)时隐藏底部档位/按钮/积分信息。 + bool get _showProgressBar => _pollHandle != null; + @override Widget build(BuildContext context) { final credits = UserState.credits.value; @@ -423,7 +505,9 @@ class _GenerateScreenState extends State { child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ - _needTwoImages ? _leftTwoSlotsColumn() : _leftSingleSlotColumn(), + _needTwoImages + ? _leftTwoSlotsColumn() + : _leftSingleSlotColumn(), const SizedBox(width: 14), _equalsColumn(), const SizedBox(width: 14), @@ -436,69 +520,130 @@ class _GenerateScreenState extends State { padding: const EdgeInsets.fromLTRB(20, 0, 20, 28), child: Column( children: [ - Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _resoChip( - '480p', - _outputSize == '480p', - () => setState(() => _outputSize = '480p'), - ), - const SizedBox(width: 12), - _resoChip( - '720p', - _outputSize == '720p', - () => setState(() => _outputSize = '720p'), - ), - ], - ), - const SizedBox(height: 12), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: PencilTheme.underlineGold, - foregroundColor: Colors.white, - minimumSize: const Size.fromHeight(54), - padding: const EdgeInsets.symmetric(vertical: 16), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(14), - ), - ), - onPressed: _busy ? null : _start, - child: _busy - ? const SizedBox( - height: 22, - width: 22, - child: CircularProgressIndicator( - strokeWidth: 2, - color: Colors.white, - ), - ) - : Text( - 'Start', - style: GoogleFonts.inter( - fontSize: 16, - fontWeight: FontWeight.w700, - ), + if (!_showProgressBar && _showResolutionToggles) + IgnorePointer( + ignoring: _generating, + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _resoChip( + '480p', + _outputSize == '480p', + () => setState(() => _outputSize = '480p'), ), - ), - const SizedBox(height: 12), - Text( - 'Est. cost · $_estimatedCost credits', - textAlign: TextAlign.center, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: PencilTheme.stone600, + const SizedBox(width: 12), + _resoChip( + '720p', + _outputSize == '720p', + () => setState(() => _outputSize = '720p'), + ), + ], + ), ), - ), - const SizedBox(height: 8), - Text( - 'Balance · ${credits.toStringAsFixed(2)}', - style: GoogleFonts.inter( - fontSize: 12, - color: PencilTheme.inkSoft, + 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), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: PencilTheme.underlineGold, + foregroundColor: Colors.white, + disabledBackgroundColor: PencilTheme.underlineGold, + disabledForegroundColor: Colors.white, + minimumSize: const Size.fromHeight(54), + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), + ), + onPressed: _generating ? null : _start, + child: _generating + ? Row( + mainAxisAlignment: MainAxisAlignment.center, + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox( + height: 22, + width: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.white, + ), + ), + const SizedBox(width: 10), + Text( + 'Loading', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ], + ) + : Text( + 'Start', + style: GoogleFonts.inter( + fontSize: 16, + fontWeight: FontWeight.w700, + ), + ), + ), + const SizedBox(height: 12), + Text( + 'Est. cost · $_estimatedCost credits', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: PencilTheme.stone600, + ), + ), + const SizedBox(height: 8), + InkWell( + onTap: () => openPurchaseStore(context), + child: Text( + 'Balance · ${credits.toStringAsFixed(2)}', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 12, + color: PencilTheme.inkSoft, + decoration: TextDecoration.underline, + decorationColor: PencilTheme.inkSoft.withValues( + alpha: 0.45, + ), + ), + ), + ), + ], ], ), ), @@ -543,9 +688,7 @@ class _GenerateScreenState extends State { child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, - children: [ - _imageSlot(slotIndex: 0, label: 'Image 1'), - ], + children: [_imageSlot(slotIndex: 0, label: 'Image 1')], ), ); } @@ -615,54 +758,18 @@ class _GenerateScreenState extends State { ); } - /// 视频预览:与 [GenerateResultScreen] / 首页背景一致,循环播放;失败则回退静态图。 + /// 视频预览:[ResilientNetworkVideoCover] 与首页一致处理 416 / 坏缓存并重试;失败则回退静态图。 Widget _buildPreviewMediaLayer(String url, String? fix) { if (_previewIsVideo) { - if (_previewVideoFailed) { - return _buildPreviewStaticImageLayer(url, fix); + final playUrl = _previewPlayUrl(widget.template); + if (playUrl == null || playUrl.isEmpty) { + return _previewPlaceholder(); } - final c = _previewVideo; - if (c == null || !c.value.isInitialized) { - return _previewPlaceholder(loading: true); - } - 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 _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 ResilientNetworkVideoCover( + key: ValueKey(playUrl), + url: playUrl, + loadingWidget: _previewPlaceholder(loading: true), + failedWidget: _buildPreviewStaticImageLayer(url, fix), ); } return _buildPreviewStaticImageLayer(url, fix); @@ -747,10 +854,7 @@ class _GenerateScreenState extends State { color: Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(16), - side: const BorderSide( - color: PencilTheme.genSlotBorder, - width: 1.5, - ), + side: const BorderSide(color: PencilTheme.genSlotBorder, width: 1.5), ), child: InkWell( onTap: () => _pickSlot(slotIndex), @@ -804,8 +908,11 @@ class _GenerateScreenState extends State { children: [ Row( children: [ - Icon(Icons.auto_awesome, - size: 18, color: PencilTheme.profileAvatarIcon), + Icon( + Icons.auto_awesome, + size: 18, + color: PencilTheme.profileAvatarIcon, + ), const SizedBox(width: 8), Text( 'Upload tips', @@ -819,7 +926,7 @@ class _GenerateScreenState extends State { ), const SizedBox(height: 8), 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( fontSize: 13, fontWeight: FontWeight.w500, @@ -834,7 +941,9 @@ class _GenerateScreenState extends State { Widget _resoChip(String t, bool on, VoidCallback fn) { return Material( - color: on ? PencilTheme.underlineGold.withValues(alpha: 0.2) : Colors.white, + color: on + ? PencilTheme.underlineGold.withValues(alpha: 0.2) + : Colors.white, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), side: BorderSide( diff --git a/lib/features/history/history_media_save.dart b/lib/features/history/history_media_save.dart new file mode 100644 index 0000000..4640e85 --- /dev/null +++ b/lib/features/history/history_media_save.dart @@ -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 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'; +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 2d12d82..e742c00 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -9,6 +9,8 @@ import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import '../generate/generate_result_screen.dart'; import 'credit_record_tab.dart'; +import 'history_media_save.dart'; +import 'history_task_progress_screen.dart'; import 'widgets/history_grid_card.dart'; /// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。 @@ -36,6 +38,7 @@ class _HistoryScreenState extends State { String? _error; List _items = []; Map _localCovers = {}; + final Set _downloadingTaskIds = {}; VoidCallback? _cancelLoginWait; @override @@ -253,21 +256,79 @@ class _HistoryScreenState extends State { (context, i) { final t = _items[i]; final id = t.taskId ?? ''; + final raw = myTaskListingRaw(t); + final display = listingDisplayFromApi(raw); + final canDl = myTaskCanShowDownload(t); + final statusLabel = myTaskStatusLabel(t); return HistoryGridCard( item: t, localCoverPath: id.isEmpty ? null : _localCovers[id], + showDownload: canDl, + statusLabel: statusLabel, + isDownloading: + id.isNotEmpty && _downloadingTaskIds.contains(id), onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => GenerateResultScreen( - taskId: id, - resultUrl: t.resultUrl?.trim() ?? '', + if (id.isEmpty) return; + // 有结果 URL 优先进预览(与列表「完成」态以地址为准一致) + if (myTaskHasRemoteResultUrl(t)) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GenerateResultScreen( + taskId: id, + resultUrl: t.resultUrl?.trim() ?? '', + ), + ), + ); + return; + } + if (myTaskIsInProgress(t)) { + Navigator.of(context).push( + MaterialPageRoute( + 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, diff --git a/lib/features/generate/generate_progress_screen.dart b/lib/features/history/history_task_progress_screen.dart similarity index 58% rename from lib/features/generate/generate_progress_screen.dart rename to lib/features/history/history_task_progress_screen.dart index 01efe5a..a793fd5 100644 --- a/lib/features/generate/generate_progress_screen.dart +++ b/lib/features/history/history_task_progress_screen.dart @@ -1,5 +1,3 @@ -import 'dart:io'; - import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; @@ -8,86 +6,87 @@ import '../../core/app_env.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; -import 'generate_result_screen.dart'; +import '../generate/generate_result_screen.dart'; -/// `YoZaK` 生成中 — 与 EYsUi 同款顶栏结构,标题「生成中」。 -class GenerateProgressScreen extends StatefulWidget { - const GenerateProgressScreen({ - super.key, - required this.taskId, - this.localPreviewPath, - }); +/// 从 My History 点「生成中」任务进入:轮询 [ImageApi.getProgress],完成后进入 [GenerateResultScreen]。 +/// +/// 与 app_client [GenerateProgressScreen] 行为对齐(权威数据源为 `/v1/image/progress`)。 +class HistoryTaskProgressScreen extends StatefulWidget { + const HistoryTaskProgressScreen({super.key, required this.taskId}); final String taskId; - final String? localPreviewPath; @override - State createState() => _GenerateProgressScreenState(); + State createState() => + _HistoryTaskProgressScreenState(); } -class _GenerateProgressScreenState extends State { +class _HistoryTaskProgressScreenState extends State { ImageProgressPollHandle? _pollHandle; - String _status = ''; - int _progress = 0; - String? _resultUrl; - String? _error; + String _genStatus = ''; + int _genProgress = 0; + String? _genResultUrl; + String? _genError; bool _navigated = false; @override void initState() { super.initState(); + _startPoll(); + } + + void _startPoll() { + _pollHandle?.cancel(); _pollHandle = ImageProgressPoll.start( app: currentBackendAppType(), taskId: widget.taskId, userId: UserState.userId.value, interval: const Duration(seconds: 5), - onTick: _onProgressTick, - onTransientNetworkFailure: (n, max) { - if (!mounted || _navigated) return; - setState(() { - _status = 'Reconnecting… ($n/$max)'; - }); - }, + onTick: _onTick, onFatalError: (msg) { if (!mounted || _navigated) return; - setState(() => _error = msg); + setState(() => _genError = msg); }, ); } - void _onProgressTick(ProgressPollTick tick) { + void _onTick(ProgressPollTick tick) { if (!mounted || _navigated) return; final res = tick.response; 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; } final p = res.data!; setState(() { - _status = p.status ?? ''; - _progress = p.progress ?? 0; - _resultUrl = p.resultUrl; + _genError = null; + _genStatus = p.status ?? ''; + _genProgress = p.progress ?? 0; + _genResultUrl = p.resultUrl; }); - final doneSuccess = ProgressPollSemantics.isSuccessTerminal(p.status) || - ProgressPollSemantics.hasUsableResultUrl(p.resultUrl); - if (doneSuccess) { + if (ProgressPollSemantics.isProgressSuccess(p)) { _navigated = true; _pollHandle?.cancel(); + _pollHandle = null; + final url = _genResultUrl ?? ''; Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => GenerateResultScreen( taskId: widget.taskId, - resultUrl: _resultUrl ?? '', + resultUrl: url, ), ), ); return; } - if (ProgressPollSemantics.isTerminalStatus(p.status) && - ProgressPollSemantics.isFailureTerminal(p.status)) { - setState(() => _error ??= 'Task failed (${p.status})'); + if (ProgressPollSemantics.isProgressFailure(p)) { + _pollHandle?.cancel(); + _pollHandle = null; + setState(() { + _genError ??= 'Task failed (${p.status})'; + }); } } @@ -106,11 +105,11 @@ class _GenerateProgressScreenState extends State { child: Scaffold( backgroundColor: Colors.transparent, body: SafeArea( - bottom: false, child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( - padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), + padding: const EdgeInsets.fromLTRB(2, 0, 14, 8), child: SizedBox( height: 56, child: Row( @@ -138,41 +137,34 @@ class _GenerateProgressScreenState extends State { ), Expanded( child: Padding( - padding: const EdgeInsets.all(24), + padding: const EdgeInsets.symmetric(horizontal: 24), child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - if (widget.localPreviewPath != 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) + if (_genError != null) ...[ Text( - _error!, + _genError!, style: GoogleFonts.inter(color: Colors.red), textAlign: TextAlign.center, - ) - else ...[ + ), + ] else ...[ LinearProgressIndicator( - value: _progress > 0 ? _progress / 100 : null, + value: _genProgress > 0 ? _genProgress / 100 : null, color: PencilTheme.underlineGold, ), const SizedBox(height: 16), Text( - _status.isEmpty ? 'Processing…' : _status, - style: GoogleFonts.inter(color: PencilTheme.stone600), + _genStatus.isEmpty ? 'Processing…' : _genStatus, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + fontSize: 14, + color: PencilTheme.stone600, + ), ), Text( - '$_progress%', + '$_genProgress%', + textAlign: TextAlign.center, style: GoogleFonts.inter( fontSize: 28, fontWeight: FontWeight.w700, diff --git a/lib/features/history/widgets/history_grid_card.dart b/lib/features/history/widgets/history_grid_card.dart index 5a400a2..00714da 100644 --- a/lib/features/history/widgets/history_grid_card.dart +++ b/lib/features/history/widgets/history_grid_card.dart @@ -20,6 +20,9 @@ class HistoryGridCard extends StatelessWidget { this.localCoverPath, this.onTap, this.onDownload, + this.showDownload = true, + this.statusLabel = '', + this.isDownloading = false, }); final MyTaskItem item; @@ -27,6 +30,13 @@ class HistoryGridCard extends StatelessWidget { final VoidCallback? onTap; final VoidCallback? onDownload; + /// 仅完成态且可下载时显示 Download;否则展示 [statusLabel](与 app_client 图库一致)。 + final bool showDownload; + final String statusLabel; + + /// 保存到相册进行中:pill 显示加载,直至 [onDownload] 结束。 + final bool isDownloading; + @override Widget build(BuildContext context) { final url = item.resultUrl?.trim() ?? ''; @@ -128,37 +138,79 @@ class HistoryGridCard extends StatelessWidget { Positioned( right: 8, bottom: 8, - child: Material( - color: Colors.white, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(21), - side: const BorderSide(color: PencilTheme.downloadPillBorder), - ), - child: InkWell( - onTap: onDownload, - borderRadius: BorderRadius.circular(21), - child: Padding( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 6), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - 'Download', - style: TextStyle( - fontFamily: 'BonheurRoyale', - fontSize: 12, - color: PencilTheme.downloadPillInk, + child: showDownload + ? Material( + color: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(21), + side: const BorderSide( + color: PencilTheme.downloadPillBorder, + ), + ), + child: AbsorbPointer( + absorbing: isDownloading, + child: InkWell( + onTap: onDownload, + borderRadius: BorderRadius.circular(21), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 12, + vertical: 6, + ), + child: isDownloading + ? SizedBox( + width: 22, + height: 22, + child: CircularProgressIndicator( + strokeWidth: 2, + color: PencilTheme.downloadPillInk, + ), + ) + : Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + 'Download', + style: TextStyle( + fontFamily: 'BonheurRoyale', + fontSize: 12, + color: PencilTheme.downloadPillInk, + ), + ), + const SizedBox(width: 4), + Icon( + 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, ), ), - const SizedBox(width: 4), - Icon(Icons.download_rounded, - size: 10, color: PencilTheme.downloadPillInk), - ], + ), ), - ), - ), - ), ), ], ), diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index 2dd7db0..f8198a8 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -4,12 +4,13 @@ import 'dart:math' show max; import 'package:cached_network_image/cached_network_image.dart'; import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_cache_manager/flutter_cache_manager.dart'; import 'package:google_fonts/google_fonts.dart'; import 'package:video_player/video_player.dart'; import '../../core/auth/auth_service.dart'; +import '../../core/open_purchase_store.dart'; import '../../core/user/user_state.dart'; +import '../../core/video_file_cache.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; import '../generate/generate_screen.dart'; @@ -64,7 +65,7 @@ class _HomeScreenState extends State { int _lastCategoryTabBarCount = 0; List _visibleExtItems(ExtConfigData? ext) => - ext?.items.where((e) => e.title.trim().isNotEmpty).toList() ?? []; + ext?.items.where((e) => e.isUsableOnHome).toList() ?? []; /// 指定顶部分类下的模板列表(视频模式用 [VideoHomeSnapshot];否则为 ext.items)。 List _templateItemsForTab( @@ -385,6 +386,7 @@ class _HomeScreenState extends State { builder: (_, credits, _) { return PencilGlassCreditsPill( amountText: credits.toStringAsFixed(2), + onTap: () => openPurchaseStore(context), ); }, ), @@ -669,6 +671,12 @@ class _HomeScreenState extends State { onPressed: () { final t = template; if (t == null) return; + final need = _homeCostDisplay480p(t); + if (need > 0 && + UserState.credits.value < need) { + openPurchaseStore(context); + return; + } Navigator.of(context).push( MaterialPageRoute( builder: (_) => GenerateScreen( @@ -734,8 +742,6 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> { ImageStream? _coverImageStream; ImageStreamListener? _coverImageListener; - static final CacheManager _videoCacheManager = DefaultCacheManager(); - String get _playUrl { final v = widget.item.videoUrl?.trim(); if (v != null && v.isNotEmpty) return v; @@ -870,14 +876,18 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> { } _disposePlayback(); + final cacheKey = videoCacheKeyForUrl(playUrl); VideoPlayerController? controller; try { if (!_forceNetworkOnly) { - final cached = await _videoCacheManager.getFileFromCache(playUrl); + final cached = await funymeeVideoCacheManager.getFileFromCache(cacheKey); if (cached != null && cached.validTill.isAfter(DateTime.now()) && - await cached.file.exists()) { + 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); @@ -931,7 +941,7 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> { } _forceNetworkOnly = false; - unawaited(_videoCacheManager.downloadFile(playUrl)); + unawaited(funymeeVideoCacheManager.downloadFile(playUrl, key: cacheKey)); setState(() {}); } @@ -953,8 +963,8 @@ class _HomeItemVideoBackgroundState extends State<_HomeItemVideoBackground> { } _recovering = true; _openRetries += 1; - final url = _playUrl; - unawaited(_videoCacheManager.removeFile(url)); + final cacheKey = videoCacheKeyForUrl(_playUrl); + unawaited(funymeeVideoCacheManager.removeFile(cacheKey)); _forceNetworkOnly = true; _disposePlayback(); setState(() {}); diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index 1a11d29..dd97626 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -4,6 +4,7 @@ import 'package:google_fonts/google_fonts.dart'; import 'package:package_info_plus/package_info_plus.dart'; import '../../core/app_env.dart'; +import '../../core/open_purchase_store.dart'; import '../../core/ext_config_document_urls.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; @@ -125,12 +126,18 @@ class _ProfileScreenState extends State { ValueListenableBuilder( valueListenable: UserState.credits, builder: (context, c, _) { - return Text( - 'Credits · ${_formatCredits(c)}', - style: GoogleFonts.inter( - fontSize: 15, - fontWeight: FontWeight.w600, - color: PencilTheme.profileCredits, + return InkWell( + onTap: () => openPurchaseStore(context), + child: Text( + 'Credits · ${_formatCredits(c)}', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: PencilTheme.profileCredits, + decoration: TextDecoration.underline, + decorationColor: PencilTheme.profileCredits + .withValues(alpha: 0.45), + ), ), ); }, diff --git a/lib/features/purchase/purchase_screen.dart b/lib/features/purchase/purchase_screen.dart index 66e9f4d..6a5a5b0 100644 --- a/lib/features/purchase/purchase_screen.dart +++ b/lib/features/purchase/purchase_screen.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:io'; 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 '../../core/app_env.dart'; +import '../../core/payment/google_play_order_recovery.dart'; import '../../core/user/user_state.dart'; import '../../design/pencil_theme.dart'; import '../../widgets/pencil_chrome.dart'; +import '../web/app_web_view_screen.dart'; /// `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 { const PurchaseScreen({super.key}); @@ -24,6 +33,13 @@ class _PurchaseScreenState extends State { String? _loadError; bool _paying = false; int? _selectedIndex; + ThirdPartyPaymentWatch? _thirdPartyWatch; + + @override + void dispose() { + _thirdPartyWatch?.dispose(); + super.dispose(); + } @override void initState() { @@ -31,7 +47,9 @@ class _PurchaseScreenState extends State { // 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). WidgetsBinding.instance.addPostFrameCallback((_) { - if (mounted) _loadProducts(isInitial: true); + if (!mounted) return; + unawaited(runGooglePlayOrderRecovery()); + _loadProducts(isInitial: true); }); } @@ -59,12 +77,285 @@ class _PurchaseScreenState extends State { }); } + bool _useThirdPartyFromExt(ExtConfigData? ext) => + ext?.allowThirdPartyPayment == true; + + Future _pickPaymentMethod( + List methods, + ) { + return showModalBottomSheet( + 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 _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( + MaterialPageRoute( + builder: (_) => + AppWebViewScreen(title: 'Payment', initialUrl: payUrl.trim()), + ), + ); + } + + if (!mounted) return; + + _thirdPartyWatch?.dispose(); + _thirdPartyWatch = ThirdPartyPaymentWatch(userId: uid, sink: sink); + _thirdPartyWatch!.start(orderId: orderId); + } + Future _onBuy(PaymentProductItem item, int index) async { final uid = UserState.userId.value; if (uid == null || uid.isEmpty) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Please sign in first.')), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(const SnackBar(content: Text('Please sign in first.'))); return; } final aid = item.activityId; @@ -76,6 +367,12 @@ class _PurchaseScreenState extends State { return; } + final ext = ExtConfigRuntime.data.value; + if (_useThirdPartyFromExt(ext)) { + await _onBuyThirdParty(item, index); + return; + } + if (!Platform.isAndroid) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -95,7 +392,12 @@ class _PurchaseScreenState extends State { final sink = _PurchaseSink( context: context, onRefresh: () { - if (mounted) setState(() => _paying = false); + if (mounted) { + setState(() { + _paying = false; + _selectedIndex = null; + }); + } }, onSuccess: () { if (mounted) setState(() {}); @@ -169,49 +471,47 @@ class _PurchaseScreenState extends State { ), ) : _loadError != null - ? Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - _loadError!, - textAlign: TextAlign.center, - style: GoogleFonts.inter( - color: PencilTheme.stone600, - ), - ), - const SizedBox(height: 16), - TextButton( - onPressed: _loadProducts, - child: Text( - 'Retry', - style: GoogleFonts.inter( - fontWeight: FontWeight.w600, - color: PencilTheme.underlineGold, - ), - ), - ), - ], + ? Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _loadError!, + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: PencilTheme.stone600, + ), ), - ), - ) - : _products.isEmpty - ? Center( + const SizedBox(height: 16), + TextButton( + onPressed: _loadProducts, child: Text( - 'No products available', + 'Retry', style: GoogleFonts.inter( - color: PencilTheme.stone600, + fontWeight: FontWeight.w600, + color: PencilTheme.underlineGold, ), ), - ) - : _ProductGrid( - products: _products, - paying: _paying, - selectedIndex: _selectedIndex, - onTap: _onBuy, ), + ], + ), + ), + ) + : _products.isEmpty + ? Center( + child: Text( + 'No products available', + style: GoogleFonts.inter(color: PencilTheme.stone600), + ), + ) + : _ProductGrid( + products: _products, + paying: _paying, + selectedIndex: _selectedIndex, + onTap: _onBuy, + ), ), ], ), @@ -329,10 +629,7 @@ class _CreditHeaderSection extends StatelessWidget { gradient: const LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [ - Color(0xFFFAE238), - Color(0xFFF5BE5D), - ], + colors: [Color(0xFFFAE238), Color(0xFFF5BE5D)], ), borderRadius: BorderRadius.circular(999), border: Border.all(color: Colors.white, width: 1.5), @@ -449,6 +746,22 @@ class _ProductCard extends StatelessWidget { 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) { final a = double.tryParse(_money.firstMatch(actual ?? '')?.group(0) ?? ''); final o = double.tryParse(_money.firstMatch(origin ?? '')?.group(0) ?? ''); @@ -462,11 +775,18 @@ class _ProductCard extends StatelessWidget { final creditsTopLabel = item.credits != null ? 'Credits:${item.credits}' : (rawTitle != null && rawTitle.trim().isNotEmpty) - ? 'Credits:${rawTitle.trim()}' - : 'Credits:—'; + ? 'Credits:${rawTitle.trim()}' + : 'Credits:—'; final actual = item.actualAmount ?? '—'; 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); return Material( @@ -525,12 +845,13 @@ class _ProductCard extends StatelessWidget { ), ), ], - if (bonus != null && bonus > 0) ...[ + if (bonusLine != null) ...[ const SizedBox(height: 6), Align( alignment: Alignment.centerRight, child: Text( - '+$bonus Bonus', + bonusLine, + textAlign: TextAlign.right, style: GoogleFonts.inter( fontSize: 12, fontWeight: FontWeight.w500, @@ -545,7 +866,7 @@ class _ProductCard extends StatelessWidget { if (pct != null && pct > 0) Positioned( right: -4, - top: -8, + top: -18, child: SizedBox( width: 60, height: 33, @@ -555,10 +876,11 @@ class _ProductCard extends StatelessWidget { Image.asset( 'assets/images/credit_tag.png', fit: BoxFit.fill, + width: 80, errorBuilder: (_, _, _) => const SizedBox.shrink(), ), Padding( - padding: const EdgeInsets.only(top: 4), + padding: const EdgeInsets.only(top: 4, right: 10), child: Text( '$pct% Off', 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), + ); + } +} diff --git a/lib/features/report/report_feedback_upload.dart b/lib/features/report/report_feedback_upload.dart new file mode 100644 index 0000000..77a302b --- /dev/null +++ b/lib/features/report/report_feedback_upload.dart @@ -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 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 = {'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'; +} diff --git a/lib/features/report/report_screen.dart b/lib/features/report/report_screen.dart index 26e8cb7..9d35fda 100644 --- a/lib/features/report/report_screen.dart +++ b/lib/features/report/report_screen.dart @@ -1,9 +1,18 @@ +import 'dart:io'; + +import 'package:client_proxy_framework/client_proxy_framework.dart'; import 'package:flutter/material.dart'; import 'package:google_fonts/google_fonts.dart'; +import 'package:image_picker/image_picker.dart'; import '../../design/pencil_theme.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 { const ReportScreen({super.key, required this.taskId}); @@ -15,6 +24,12 @@ class ReportScreen extends StatefulWidget { class _ReportScreenState extends State { 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 void dispose() { @@ -22,6 +37,70 @@ class _ReportScreenState extends State { super.dispose(); } + Future _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 _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 = []; + 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 Widget build(BuildContext context) { return Container( @@ -32,6 +111,7 @@ class _ReportScreenState extends State { backgroundColor: Colors.transparent, body: SafeArea( child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Padding( padding: const EdgeInsets.fromLTRB(2, 0, 14, 10), @@ -40,7 +120,10 @@ class _ReportScreenState extends State { child: Row( children: [ PencilRoundBackButton( - onPressed: () => Navigator.of(context).pop(), + onPressed: () { + if (_submitting) return; + Navigator.of(context).pop(); + }, ), Expanded( child: Center( @@ -61,50 +144,187 @@ class _ReportScreenState extends State { ), ), Expanded( - child: Padding( - padding: const EdgeInsets.all(20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text( - 'Task: ${widget.taskId}', - style: GoogleFonts.inter(color: PencilTheme.stone600), + child: ListView( + padding: const EdgeInsets.fromLTRB(20, 0, 20, 24), + children: [ + Text( + 'Tell us what went wrong.', + style: GoogleFonts.inter( + fontSize: 14, + height: 1.4, + color: PencilTheme.stone600, ), - const SizedBox(height: 16), - TextField( - controller: _controller, - maxLines: 5, - decoration: InputDecoration( - hintText: 'Describe the issue…', - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: const BorderSide( - color: PencilTheme.genNavBackStroke), + ), + 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( + controller: _controller, + maxLines: 6, + readOnly: _submitting, + style: GoogleFonts.inter( + fontSize: 15, + height: 1.45, + color: PencilTheme.stone900, + ), + cursorColor: PencilTheme.underlineGold, + decoration: InputDecoration( + hintText: 'Describe the issue in detail…', + hintStyle: GoogleFonts.inter( + fontSize: 15, + height: 1.45, + color: PencilTheme.stone700, + ), + filled: true, + fillColor: Colors.white, + 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), + borderSide: const BorderSide( + 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), - FilledButton( - style: FilledButton.styleFrom( - backgroundColor: PencilTheme.underlineGold, - foregroundColor: Colors.white, + ), + 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, + ), + ), + ), + ), ), - onPressed: () { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Submit wired in FeedbackApi (see §13).', + 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, + ), ), ), - ); - Navigator.of(context).pop(); - }, - child: const Text('Submit'), + ), + ], + ], + ), + const SizedBox(height: 28), + FilledButton( + style: FilledButton.styleFrom( + backgroundColor: PencilTheme.underlineGold, + foregroundColor: Colors.white, + minimumSize: const Size.fromHeight(52), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + ), ), - ], - ), + onPressed: () { + if (_submitting) return; + _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, + ), + ), + ), + ], ), ), ], diff --git a/lib/features/shell/main_screen.dart b/lib/features/shell/main_screen.dart index 24e8579..08c51bc 100644 --- a/lib/features/shell/main_screen.dart +++ b/lib/features/shell/main_screen.dart @@ -1,5 +1,9 @@ +import 'dart:async'; + 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 '../home/home_screen.dart'; import '../profile/profile_screen.dart'; @@ -14,6 +18,23 @@ class MainScreen extends StatefulWidget { class _MainScreenState extends State { int _index = 0; + VoidCallback? _cancelLoginRecoveryHook; + + @override + void initState() { + super.initState(); + _cancelLoginRecoveryHook = AuthService.whenLoginSucceeded( + onReady: () { + unawaited(runGooglePlayOrderRecovery()); + }, + ); + } + + @override + void dispose() { + _cancelLoginRecoveryHook?.call(); + super.dispose(); + } @override Widget build(BuildContext context) { diff --git a/lib/widgets/pencil_chrome.dart b/lib/widgets/pencil_chrome.dart index 3ac68b7..7b3c552 100644 --- a/lib/widgets/pencil_chrome.dart +++ b/lib/widgets/pencil_chrome.dart @@ -47,84 +47,111 @@ class PencilGlassCreditsPill extends StatelessWidget { const PencilGlassCreditsPill({ super.key, required this.amountText, + this.onTap, }); final String amountText; + final VoidCallback? onTap; @override Widget build(BuildContext context) { + final row = Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.diamond_rounded, + size: 18, color: PencilTheme.gemYellow), + const SizedBox(width: 8), + Text( + amountText, + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: PencilTheme.homeTextPrimary, + ), + ), + ], + ); return ClipRRect( 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, - children: [ - const Icon(Icons.diamond_rounded, - size: 18, color: PencilTheme.gemYellow), - const SizedBox(width: 8), - Text( - amountText, - style: GoogleFonts.inter( - fontSize: 15, - fontWeight: FontWeight.w600, - color: PencilTheme.homeTextPrimary, + 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 { const PencilCreateNowButton({super.key, required this.onPressed}); final VoidCallback onPressed; + static const double _w = 212; + static const double _h = 50; + @override Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(999), - child: BackdropFilter( - filter: ImageFilter.blur(sigmaX: 28, sigmaY: 28), - child: Material( - color: PencilTheme.createPillFill, - child: InkWell( - onTap: onPressed, - child: SizedBox( - width: 186, - height: 40, - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - 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', - style: GoogleFonts.inter( - fontSize: 18, - fontWeight: FontWeight.w700, - color: PencilTheme.homeTextPrimary, - letterSpacing: 0.3, - ), - ), - ], + return Material( + color: Colors.transparent, + child: InkWell( + 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( + width: _w, + height: _h, + child: Center( + child: Text( + 'Create Now', + style: GoogleFonts.inter( + fontSize: 18, + fontWeight: FontWeight.w800, + color: PencilTheme.stone900, + letterSpacing: 0.4, + ), ), ), ), diff --git a/lib/widgets/resilient_network_video.dart b/lib/widgets/resilient_network_video.dart new file mode 100644 index 0000000..4ad6eb8 --- /dev/null +++ b/lib/widgets/resilient_network_video.dart @@ -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 createState() => + _ResilientNetworkVideoCoverState(); +} + +class _ResilientNetworkVideoCoverState extends State { + 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 _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 _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), + ), + ), + ), + ), + ), + ], + ); + }, + ); + } +}