优化:第一版本打包

This commit is contained in:
ivan 2026-04-13 22:25:08 +08:00
parent 8cc9fe7ce5
commit 85bed1cda2
23 changed files with 2009 additions and 603 deletions

View File

@ -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

View File

@ -0,0 +1,5 @@
package com.funymeeai.funymee_ai
import io.flutter.embedding.android.FlutterActivity
class MainActivity : FlutterActivity()

View File

@ -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,

View File

@ -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
},
"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": [ "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
} }
] ]
} }

View File

@ -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;

View 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()),
);
}

View 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!);
},
);
}
}

View 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;
}
}

View File

@ -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);

View File

@ -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,69 +296,7 @@ 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(

View File

@ -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,69 +520,130 @@ 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)
mainAxisAlignment: MainAxisAlignment.center, IgnorePointer(
children: [ ignoring: _generating,
_resoChip( child: Row(
'480p', mainAxisAlignment: MainAxisAlignment.center,
_outputSize == '480p', children: [
() => setState(() => _outputSize = '480p'), _resoChip(
), '480p',
const SizedBox(width: 12), _outputSize == '480p',
_resoChip( () => setState(() => _outputSize = '480p'),
'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,
),
), ),
), const SizedBox(width: 12),
const SizedBox(height: 12), _resoChip(
Text( '720p',
'Est. cost · $_estimatedCost credits', _outputSize == '720p',
textAlign: TextAlign.center, () => setState(() => _outputSize = '720p'),
style: GoogleFonts.inter( ),
fontSize: 13, ],
fontWeight: FontWeight.w600, ),
color: PencilTheme.stone600,
), ),
), if (_genError != null) ...[
const SizedBox(height: 8), const SizedBox(height: 16),
Text( Text(
'Balance · ${credits.toStringAsFixed(2)}', _genError!,
style: GoogleFonts.inter( style: GoogleFonts.inter(color: Colors.red),
fontSize: 12, textAlign: TextAlign.center,
color: PencilTheme.inkSoft,
), ),
), ] 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<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(

View 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';
}

View File

@ -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 Tab24h /// `WBRp4` My History Tab24h
@ -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,21 +256,79 @@ 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: () {
Navigator.of(context).push( if (id.isEmpty) return;
MaterialPageRoute<void>( // URL
builder: (_) => GenerateResultScreen( if (myTaskHasRemoteResultUrl(t)) {
taskId: id, Navigator.of(context).push(
resultUrl: t.resultUrl?.trim() ?? '', MaterialPageRoute<void>(
builder: (_) => GenerateResultScreen(
taskId: id,
resultUrl: t.resultUrl?.trim() ?? '',
),
),
);
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,

View File

@ -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,

View File

@ -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,37 +138,79 @@ class HistoryGridCard extends StatelessWidget {
Positioned( Positioned(
right: 8, right: 8,
bottom: 8, bottom: 8,
child: Material( child: showDownload
color: Colors.white, ? Material(
shape: RoundedRectangleBorder( color: Colors.white,
borderRadius: BorderRadius.circular(21), shape: RoundedRectangleBorder(
side: const BorderSide(color: PencilTheme.downloadPillBorder), borderRadius: BorderRadius.circular(21),
), side: const BorderSide(
child: InkWell( color: PencilTheme.downloadPillBorder,
onTap: onDownload, ),
borderRadius: BorderRadius.circular(21), ),
child: Padding( child: AbsorbPointer(
padding: const EdgeInsets.symmetric( absorbing: isDownloading,
horizontal: 12, vertical: 6), child: InkWell(
child: Row( onTap: onDownload,
mainAxisSize: MainAxisSize.min, borderRadius: BorderRadius.circular(21),
children: [ child: Padding(
Text( padding: const EdgeInsets.symmetric(
'Download', horizontal: 12,
style: TextStyle( vertical: 6,
fontFamily: 'BonheurRoyale', ),
fontSize: 12, child: isDownloading
color: PencilTheme.downloadPillInk, ? 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),
],
), ),
),
),
),
), ),
], ],
), ),

View File

@ -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(() {});

View File

@ -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(
'Credits · ${_formatCredits(c)}', onTap: () => openPurchaseStore(context),
style: GoogleFonts.inter( child: Text(
fontSize: 15, 'Credits · ${_formatCredits(c)}',
fontWeight: FontWeight.w600, style: GoogleFonts.inter(
color: PencilTheme.profileCredits, fontSize: 15,
fontWeight: FontWeight.w600,
color: PencilTheme.profileCredits,
decoration: TextDecoration.underline,
decorationColor: PencilTheme.profileCredits
.withValues(alpha: 0.45),
),
), ),
); );
}, },

View File

@ -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 PointBonheur + [xiabiao] + [credit_tag] /// `ETbdo` Purchase PointBonheur + [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(() {});
@ -169,49 +471,47 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
), ),
) )
: _loadError != null : _loadError != null
? Center( ? Center(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Text( Text(
_loadError!, _loadError!,
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: GoogleFonts.inter( style: GoogleFonts.inter(
color: PencilTheme.stone600, color: PencilTheme.stone600,
), ),
),
const SizedBox(height: 16),
TextButton(
onPressed: _loadProducts,
child: Text(
'Retry',
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: PencilTheme.underlineGold,
),
),
),
],
), ),
), const SizedBox(height: 16),
) TextButton(
: _products.isEmpty onPressed: _loadProducts,
? Center(
child: Text( child: Text(
'No products available', 'Retry',
style: GoogleFonts.inter( 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( 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) ?? '');
@ -462,11 +775,18 @@ class _ProductCard extends StatelessWidget {
final creditsTopLabel = item.credits != null final creditsTopLabel = item.credits != null
? 'Credits:${item.credits}' ? 'Credits:${item.credits}'
: (rawTitle != null && rawTitle.trim().isNotEmpty) : (rawTitle != null && rawTitle.trim().isNotEmpty)
? 'Credits:${rawTitle.trim()}' ? 'Credits:${rawTitle.trim()}'
: '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),
);
}
}

View 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';
}

View File

@ -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,50 +144,187 @@ 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( children: [
crossAxisAlignment: CrossAxisAlignment.stretch, Text(
children: [ 'Tell us what went wrong.',
Text( style: GoogleFonts.inter(
'Task: ${widget.taskId}', fontSize: 14,
style: GoogleFonts.inter(color: PencilTheme.stone600), height: 1.4,
color: PencilTheme.stone600,
), ),
const SizedBox(height: 16), ),
TextField( const SizedBox(height: 16),
controller: _controller, Text(
maxLines: 5, 'Related task',
decoration: InputDecoration( style: GoogleFonts.inter(
hintText: 'Describe the issue…', fontSize: 13,
filled: true, fontWeight: FontWeight.w600,
fillColor: Colors.white, color: PencilTheme.stone900,
border: OutlineInputBorder( ),
borderRadius: BorderRadius.circular(12), ),
borderSide: const BorderSide( const SizedBox(height: 6),
color: PencilTheme.genNavBackStroke), 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( const SizedBox(height: 20),
style: FilledButton.styleFrom( Text(
backgroundColor: PencilTheme.underlineGold, 'Screenshot (optional, one image only)',
foregroundColor: Colors.white, 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: () { if (_imageFile != null) ...[
ScaffoldMessenger.of(context).showSnackBar( const SizedBox(width: 12),
const SnackBar( Padding(
content: Text( padding: const EdgeInsets.only(top: 4),
'Submit wired in FeedbackApi (see §13).', 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,
),
),
),
],
), ),
), ),
], ],

View File

@ -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) {

View File

@ -47,84 +47,111 @@ 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) {
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( return ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: BackdropFilter( child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: Container( child: onTap != null
padding: const EdgeInsets.symmetric(horizontal: 14), ? Material(
color: PencilTheme.homeGlassFill, color: PencilTheme.homeGlassFill,
height: 35, child: InkWell(
child: Row( onTap: onTap,
mainAxisSize: MainAxisSize.min, child: Container(
children: [ padding: const EdgeInsets.symmetric(horizontal: 14),
const Icon(Icons.diamond_rounded, height: 35,
size: 18, color: PencilTheme.gemYellow), alignment: Alignment.centerLeft,
const SizedBox(width: 8), child: row,
Text( ),
amountText,
style: GoogleFonts.inter(
fontSize: 15,
fontWeight: FontWeight.w600,
color: PencilTheme.homeTextPrimary,
), ),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
color: PencilTheme.homeGlassFill,
height: 35,
child: row,
), ),
],
),
),
), ),
); );
} }
} }
/// bi8Au Create Now186 40pill 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( child: InkWell(
filter: ImageFilter.blur(sigmaX: 28, sigmaY: 28), onTap: onPressed,
child: Material( borderRadius: BorderRadius.circular(999),
color: PencilTheme.createPillFill, child: Ink(
child: InkWell( decoration: BoxDecoration(
onTap: onPressed, borderRadius: BorderRadius.circular(999),
child: SizedBox( gradient: const LinearGradient(
width: 186, begin: Alignment.topCenter,
height: 40, end: Alignment.bottomCenter,
child: Row( colors: [
mainAxisAlignment: MainAxisAlignment.center, Color(0xFFFFFDE7),
children: [ Color(0xFFFDE047),
Container( Color(0xFFF59E0B),
width: 25, ],
height: 25, stops: [0.0, 0.42, 1.0],
decoration: const BoxDecoration( ),
color: PencilTheme.createPlusDisc, border: Border.all(
shape: BoxShape.circle, color: Color(0xD9FFFFFF),
), width: 2,
child: const Icon(Icons.add, size: 14, color: Colors.black), ),
), boxShadow: const [
const SizedBox(width: 14), BoxShadow(
Text( color: Color(0x52B45309),
'Create Now', offset: Offset(0, 10),
style: GoogleFonts.inter( blurRadius: 28,
fontSize: 18, ),
fontWeight: FontWeight.w700, ],
color: PencilTheme.homeTextPrimary, ),
letterSpacing: 0.3, 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,
),
), ),
), ),
), ),

View 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),
),
),
),
),
),
],
);
},
);
}
}