优化:第一版本打包

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

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"],
"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,

View File

@ -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
},
"gap": 14,
"colors": [
{
"color": "#FFFDE7",
"position": 0
},
{
"color": "#FDE047",
"position": 0.42
},
{
"color": "#F59E0B",
"position": 1
}
]
},
"cornerRadius": 999,
"stroke": {
"align": "inside",
"thickness": 2,
"fill": "#FFFFFFD9"
},
"effect": {
"type": "shadow",
"shadowType": "outer",
"color": "#B4530952",
"offset": {
"x": 0,
"y": 10
},
"blur": 28
},
"gap": 12,
"padding": [
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
}
]
}

View File

@ -39,6 +39,7 @@ abstract final class ExtConfigDocumentUrls {
return ExtConfigData.fromJson(
Map<String, dynamic>.from(raw),
schema: schema,
fieldMapping: cfg.fieldMapping,
);
} catch (_) {
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 gemYellow = Color(0xFFFFD60A);
/// Create Now pill
/// Create Now UI [PencilCreateNowButton]
static const Color createPillFill = Color(0x4DFFFFFF);
static const Color createPlusDisc = Color(0xFFFFD60A);

View File

@ -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<GenerateResultScreen> {
VideoPlayerController? _video;
bool _videoInitFailed = false;
bool _saving = false;
bool get _hasUrl {
@ -43,43 +41,6 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
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 {
if (!_hasUrl || _saving) return;
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(
alignment: Alignment.bottomCenter,
child: _ResultBottomBar(
@ -290,8 +277,17 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
);
}
if (_isVideo) {
if (_videoInitFailed) {
return ColoredBox(
final u = widget.resultUrl.trim();
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,
child: Center(
child: Icon(
@ -300,71 +296,9 @@ class _GenerateResultScreenState extends State<GenerateResultScreen> {
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(
child: CachedNetworkImage(
imageUrl: widget.resultUrl.trim(),

View File

@ -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<GenerateScreen> {
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<GenerateScreen> {
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<void> _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<GenerateScreen> {
Future<void> _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<GenerateScreen> {
}
}
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<GenerateScreen> {
);
if (!mounted) return;
Navigator.of(context).pushReplacement(
MaterialPageRoute<void>(
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<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);
}
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
void dispose() {
_disposePreviewVideo();
_pollHandle?.cancel();
super.dispose();
}
@ -372,6 +451,9 @@ class _GenerateScreenState extends State<GenerateScreen> {
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<GenerateScreen> {
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
_needTwoImages ? _leftTwoSlotsColumn() : _leftSingleSlotColumn(),
_needTwoImages
? _leftTwoSlotsColumn()
: _leftSingleSlotColumn(),
const SizedBox(width: 14),
_equalsColumn(),
const SizedBox(width: 14),
@ -436,7 +520,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
padding: const EdgeInsets.fromLTRB(20, 0, 20, 28),
child: Column(
children: [
Row(
if (!_showProgressBar && _showResolutionToggles)
IgnorePointer(
ignoring: _generating,
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_resoChip(
@ -452,26 +539,75 @@ class _GenerateScreenState extends State<GenerateScreen> {
),
],
),
),
if (_genError != null) ...[
const SizedBox(height: 16),
Text(
_genError!,
style: GoogleFonts.inter(color: Colors.red),
textAlign: TextAlign.center,
),
] else if (_showProgressBar) ...[
const SizedBox(height: 16),
LinearProgressIndicator(
value: _genProgress > 0 ? _genProgress / 100 : null,
color: PencilTheme.underlineGold,
),
const SizedBox(height: 10),
Text(
_genStatus.isEmpty ? 'Processing…' : _genStatus,
style: GoogleFonts.inter(
fontSize: 13,
color: PencilTheme.stone600,
),
textAlign: TextAlign.center,
),
Text(
'$_genProgress%',
style: GoogleFonts.inter(
fontSize: 22,
fontWeight: FontWeight.w700,
color: PencilTheme.stone900,
),
),
],
if (!_showProgressBar) ...[
const SizedBox(height: 12),
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: _busy ? null : _start,
child: _busy
? const SizedBox(
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',
@ -492,13 +628,22 @@ class _GenerateScreenState extends State<GenerateScreen> {
),
),
const SizedBox(height: 8),
Text(
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(
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<GenerateScreen> {
);
}
/// [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<String>(playUrl),
url: playUrl,
loadingWidget: _previewPlaceholder(loading: true),
failedWidget: _buildPreviewStaticImageLayer(url, fix),
);
}
return _buildPreviewStaticImageLayer(url, fix);
@ -747,10 +854,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
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<GenerateScreen> {
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<GenerateScreen> {
),
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<GenerateScreen> {
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(

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 '../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 Tab24h
@ -36,6 +38,7 @@ class _HistoryScreenState extends State<HistoryScreen> {
String? _error;
List<MyTaskItem> _items = [];
Map<String, String> _localCovers = {};
final Set<String> _downloadingTaskIds = {};
VoidCallback? _cancelLoginWait;
@override
@ -253,11 +256,22 @@ class _HistoryScreenState extends State<HistoryScreen> {
(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: () {
if (id.isEmpty) return;
// URL
if (myTaskHasRemoteResultUrl(t)) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => GenerateResultScreen(
@ -266,8 +280,55 @@ class _HistoryScreenState extends State<HistoryScreen> {
),
),
);
return;
}
if (myTaskIsInProgress(t)) {
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) =>
HistoryTaskProgressScreen(taskId: id),
),
);
return;
}
if (galleryListingIsFinishedSuccess(raw, display)) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Media is not ready yet. Pull to refresh.',
),
),
);
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
galleryListingBlockedHint(raw, display),
),
),
);
},
onDownload: () {},
onDownload: canDl
? () async {
final u = t.resultUrl?.trim() ?? '';
if (u.isEmpty || id.isEmpty) return;
setState(() => _downloadingTaskIds.add(id));
try {
await saveHistoryMediaToGallery(
context: context,
taskId: id,
resultUrl: u,
);
} finally {
if (mounted) {
setState(
() => _downloadingTaskIds.remove(id),
);
}
}
}
: null,
);
},
childCount: _items.length,

View File

@ -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<GenerateProgressScreen> createState() => _GenerateProgressScreenState();
State<HistoryTaskProgressScreen> createState() =>
_HistoryTaskProgressScreenState();
}
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
class _HistoryTaskProgressScreenState extends State<HistoryTaskProgressScreen> {
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<void>(
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<GenerateProgressScreen> {
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<GenerateProgressScreen> {
),
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,

View File

@ -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,19 +138,35 @@ class HistoryGridCard extends StatelessWidget {
Positioned(
right: 8,
bottom: 8,
child: Material(
child: showDownload
? Material(
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(21),
side: const BorderSide(color: PencilTheme.downloadPillBorder),
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: Row(
horizontal: 12,
vertical: 6,
),
child: isDownloading
? SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: PencilTheme.downloadPillInk,
),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
@ -152,13 +178,39 @@ class HistoryGridCard extends StatelessWidget {
),
),
const SizedBox(width: 4),
Icon(Icons.download_rounded,
size: 10, color: PencilTheme.downloadPillInk),
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,
),
),
),
),
),
],
),

View File

@ -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<HomeScreen> {
int _lastCategoryTabBarCount = 0;
List<ExtConfigItem> _visibleExtItems(ExtConfigData? ext) =>
ext?.items.where((e) => e.title.trim().isNotEmpty).toList() ?? [];
ext?.items.where((e) => e.isUsableOnHome).toList() ?? [];
/// [VideoHomeSnapshot] ext.items
List<ExtConfigItem> _templateItemsForTab(
@ -385,6 +386,7 @@ class _HomeScreenState extends State<HomeScreen> {
builder: (_, credits, _) {
return PencilGlassCreditsPill(
amountText: credits.toStringAsFixed(2),
onTap: () => openPurchaseStore(context),
);
},
),
@ -669,6 +671,12 @@ class _HomeScreenState extends State<HomeScreen> {
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<void>(
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(() {});

View File

@ -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<ProfileScreen> {
ValueListenableBuilder<int>(
valueListenable: UserState.credits,
builder: (context, c, _) {
return Text(
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),
),
),
);
},

View File

@ -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 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 {
const PurchaseScreen({super.key});
@ -24,6 +33,13 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
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<PurchaseScreen> {
// 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<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 {
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<PurchaseScreen> {
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<PurchaseScreen> {
final sink = _PurchaseSink(
context: context,
onRefresh: () {
if (mounted) setState(() => _paying = false);
if (mounted) {
setState(() {
_paying = false;
_selectedIndex = null;
});
}
},
onSuccess: () {
if (mounted) setState(() {});
@ -201,9 +503,7 @@ class _PurchaseScreenState extends State<PurchaseScreen> {
? Center(
child: Text(
'No products available',
style: GoogleFonts.inter(
color: PencilTheme.stone600,
),
style: GoogleFonts.inter(color: PencilTheme.stone600),
),
)
: _ProductGrid(
@ -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) ?? '');
@ -466,7 +779,14 @@ class _ProductCard extends StatelessWidget {
: '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),
);
}
}

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: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<ReportScreen> {
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<ReportScreen> {
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
Widget build(BuildContext context) {
return Container(
@ -32,6 +111,7 @@ class _ReportScreenState extends State<ReportScreen> {
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<ReportScreen> {
child: Row(
children: [
PencilRoundBackButton(
onPressed: () => Navigator.of(context).pop(),
onPressed: () {
if (_submitting) return;
Navigator.of(context).pop();
},
),
Expanded(
child: Center(
@ -61,52 +144,189 @@ class _ReportScreenState extends State<ReportScreen> {
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
child: ListView(
padding: const EdgeInsets.fromLTRB(20, 0, 20, 24),
children: [
Text(
'Task: ${widget.taskId}',
style: GoogleFonts.inter(color: PencilTheme.stone600),
'Tell us what went wrong.',
style: GoogleFonts.inter(
fontSize: 14,
height: 1.4,
color: PencilTheme.stone600,
),
),
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: 5,
maxLines: 6,
readOnly: _submitting,
style: GoogleFonts.inter(
fontSize: 15,
height: 1.45,
color: PencilTheme.stone900,
),
cursorColor: PencilTheme.underlineGold,
decoration: InputDecoration(
hintText: 'Describe the issue…',
hintText: 'Describe the issue in detail…',
hintStyle: GoogleFonts.inter(
fontSize: 15,
height: 1.45,
color: PencilTheme.stone700,
),
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: PencilTheme.stone600.withValues(alpha: 0.55),
width: 1,
),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: PencilTheme.genNavBackStroke),
color: PencilTheme.underlineGold,
width: 1.5,
),
),
disabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(
color: PencilTheme.stone600.withValues(alpha: 0.4),
),
),
),
const SizedBox(height: 24),
),
const SizedBox(height: 20),
Text(
'Screenshot (optional, one image only)',
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: PencilTheme.stone900,
),
),
const SizedBox(height: 10),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Material(
color: Colors.transparent,
child: InkWell(
onTap: _submitting ? null : _pickImage,
borderRadius: BorderRadius.circular(14),
child: Ink(
width: 112,
height: 112,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(
color: PencilTheme.genNavBackStroke,
),
),
child: _imageFile == null
? Icon(
Icons.add_photo_alternate_outlined,
size: 36,
color: PencilTheme.stone600.withValues(
alpha: 0.7,
),
)
: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
_imageFile!,
fit: BoxFit.cover,
width: double.infinity,
height: double.infinity,
),
),
),
),
),
if (_imageFile != null) ...[
const SizedBox(width: 12),
Padding(
padding: const EdgeInsets.only(top: 4),
child: TextButton(
onPressed: _submitting ? null : _clearImage,
child: Text(
'Remove',
style: GoogleFonts.inter(
fontWeight: FontWeight.w600,
color: PencilTheme.stone700,
),
),
),
),
],
],
),
const SizedBox(height: 28),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: PencilTheme.underlineGold,
foregroundColor: Colors.white,
minimumSize: const Size.fromHeight(52),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
),
),
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text(
'Submit wired in FeedbackApi (see §13).',
),
),
);
Navigator.of(context).pop();
if (_submitting) return;
_submit();
},
child: const Text('Submit'),
child: _submitting
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Colors.white,
),
)
: Text(
'Submit',
style: GoogleFonts.inter(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
),
],
),
),
),
],
),
),

View File

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

View File

@ -47,21 +47,15 @@ class PencilGlassCreditsPill extends StatelessWidget {
const PencilGlassCreditsPill({
super.key,
required this.amountText,
this.onTap,
});
final String amountText;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
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(
final row = Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.diamond_rounded,
@ -76,56 +70,89 @@ class PencilGlassCreditsPill extends StatelessWidget {
),
),
],
);
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20),
child: onTap != null
? Material(
color: PencilTheme.homeGlassFill,
child: InkWell(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
height: 35,
alignment: Alignment.centerLeft,
child: row,
),
),
)
: Container(
padding: const EdgeInsets.symmetric(horizontal: 14),
color: PencilTheme.homeGlassFill,
height: 35,
child: row,
),
),
);
}
}
/// bi8Au Create Now 186 40pill 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,
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: 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(
width: _w,
height: _h,
child: Center(
child: Text(
'Create Now',
style: GoogleFonts.inter(
fontSize: 18,
fontWeight: FontWeight.w700,
color: PencilTheme.homeTextPrimary,
letterSpacing: 0.3,
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),
),
),
),
),
),
],
);
},
);
}
}