优化:细节调整
This commit is contained in:
parent
22f712c5a8
commit
8cc9fe7ce5
@ -1,5 +1,8 @@
|
|||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission
|
||||||
|
android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||||
|
android:maxSdkVersion="29" />
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES" />
|
||||||
<uses-permission
|
<uses-permission
|
||||||
@ -30,7 +33,8 @@
|
|||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="FunyMee AI"
|
android:label="FunyMee AI"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:requestLegacyExternalStorage="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
|
|||||||
BIN
assets/images/card_texture_glow_lines.png
Normal file
BIN
assets/images/card_texture_glow_lines.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
@ -91,6 +91,8 @@
|
|||||||
"image": ["image"],
|
"image": ["image"],
|
||||||
"imgNeed": ["img_need"],
|
"imgNeed": ["img_need"],
|
||||||
"cost": ["cost"],
|
"cost": ["cost"],
|
||||||
|
"cost480p": ["cost_480p", "cost480p", "cost_480"],
|
||||||
|
"cost720p": ["cost_720p", "cost720p", "cost_720"],
|
||||||
"title": ["title"],
|
"title": ["title"],
|
||||||
"params": ["params"],
|
"params": ["params"],
|
||||||
"detail": ["detail"],
|
"detail": ["detail"],
|
||||||
|
|||||||
@ -84,5 +84,7 @@
|
|||||||
<string>FunyMee needs camera access to take photos for generation.</string>
|
<string>FunyMee needs camera access to take photos for generation.</string>
|
||||||
<key>NSPhotoLibraryUsageDescription</key>
|
<key>NSPhotoLibraryUsageDescription</key>
|
||||||
<string>FunyMee needs photo library access to choose images for generation.</string>
|
<string>FunyMee needs photo library access to choose images for generation.</string>
|
||||||
|
<key>NSPhotoLibraryAddUsageDescription</key>
|
||||||
|
<string>FunyMee saves your generated image or video to your photo library when you tap Download.</string>
|
||||||
</dict>
|
</dict>
|
||||||
</plist>
|
</plist>
|
||||||
|
|||||||
@ -87,4 +87,37 @@ class AuthService {
|
|||||||
/// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。
|
/// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。
|
||||||
static ValueNotifier<bool> get isLoginComplete =>
|
static ValueNotifier<bool> get isLoginComplete =>
|
||||||
FrameworkAuthService.isLoginComplete;
|
FrameworkAuthService.isLoginComplete;
|
||||||
|
|
||||||
|
/// 在 **登录成功**([UserState.userId] 非空)且 [isLoginComplete] 为 `true` 之后执行 [onReady]。
|
||||||
|
///
|
||||||
|
/// 若登录流程已结束但无用户(失败),调用一次 [onFailed]。若 [isLoginComplete] 已为 `true`,同步执行。
|
||||||
|
///
|
||||||
|
/// 返回在 [State.dispose] 中调用的取消函数,避免界面已销毁仍监听。
|
||||||
|
static VoidCallback whenLoginSucceeded({
|
||||||
|
required VoidCallback onReady,
|
||||||
|
VoidCallback? onFailed,
|
||||||
|
}) {
|
||||||
|
void runOnce() {
|
||||||
|
final uid = UserState.userId.value;
|
||||||
|
if (uid != null && uid.isNotEmpty) {
|
||||||
|
onReady();
|
||||||
|
} else {
|
||||||
|
onFailed?.call();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void listener() {
|
||||||
|
if (!isLoginComplete.value) return;
|
||||||
|
isLoginComplete.removeListener(listener);
|
||||||
|
runOnce();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoginComplete.value) {
|
||||||
|
runOnce();
|
||||||
|
return () {};
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoginComplete.addListener(listener);
|
||||||
|
return () => isLoginComplete.removeListener(listener);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,6 +51,16 @@ abstract final class PencilTheme {
|
|||||||
static const Color genSlotBorder = Color(0xFFF5D08A);
|
static const Color genSlotBorder = Color(0xFFF5D08A);
|
||||||
static const Color genNavBackStroke = Color(0xFFE7E5E4);
|
static const Color genNavBackStroke = Color(0xFFE7E5E4);
|
||||||
|
|
||||||
|
/// Credit Record 流水行卡片(`funymee_home.pen` listCr / ez9wP)
|
||||||
|
static const LinearGradient creditRecordRowGradient = LinearGradient(
|
||||||
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
Color(0xFFFDE047),
|
||||||
|
Color(0xFFF59E0B),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
/// 设计宽度用于按比例缩放(可选)。
|
/// 设计宽度用于按比例缩放(可选)。
|
||||||
static const double designWidth = 390;
|
static const double designWidth = 390;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,3 @@
|
|||||||
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';
|
||||||
@ -27,29 +26,38 @@ class GenerateProgressScreen extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||||
Timer? _timer;
|
ImageProgressPollHandle? _pollHandle;
|
||||||
String _status = '';
|
String _status = '';
|
||||||
int _progress = 0;
|
int _progress = 0;
|
||||||
String? _resultUrl;
|
String? _resultUrl;
|
||||||
String? _error;
|
String? _error;
|
||||||
bool _finished = false;
|
bool _navigated = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_poll();
|
_pollHandle = ImageProgressPoll.start(
|
||||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _poll());
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _poll() async {
|
|
||||||
if (_error != null || _finished) return;
|
|
||||||
final uid = UserState.userId.value;
|
|
||||||
final res = await ImageApi.getProgress(
|
|
||||||
app: currentBackendAppType(),
|
app: currentBackendAppType(),
|
||||||
taskId: widget.taskId,
|
taskId: widget.taskId,
|
||||||
userId: uid,
|
userId: UserState.userId.value,
|
||||||
|
interval: const Duration(seconds: 5),
|
||||||
|
onTick: _onProgressTick,
|
||||||
|
onTransientNetworkFailure: (n, max) {
|
||||||
|
if (!mounted || _navigated) return;
|
||||||
|
setState(() {
|
||||||
|
_status = 'Reconnecting… ($n/$max)';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onFatalError: (msg) {
|
||||||
|
if (!mounted || _navigated) return;
|
||||||
|
setState(() => _error = msg);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (!mounted) return;
|
}
|
||||||
|
|
||||||
|
void _onProgressTick(ProgressPollTick tick) {
|
||||||
|
if (!mounted || _navigated) return;
|
||||||
|
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(() => _error = res.msg.isNotEmpty ? res.msg : 'Progress error');
|
||||||
return;
|
return;
|
||||||
@ -61,11 +69,11 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
_resultUrl = p.resultUrl;
|
_resultUrl = p.resultUrl;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (_isTerminal(_status) || _hasUsableResult(_resultUrl)) {
|
final doneSuccess = ProgressPollSemantics.isSuccessTerminal(p.status) ||
|
||||||
_timer?.cancel();
|
ProgressPollSemantics.hasUsableResultUrl(p.resultUrl);
|
||||||
if (_isSuccess(_status) || _hasUsableResult(_resultUrl)) {
|
if (doneSuccess) {
|
||||||
_finished = true;
|
_navigated = true;
|
||||||
if (!mounted) return;
|
_pollHandle?.cancel();
|
||||||
Navigator.of(context).pushReplacement(
|
Navigator.of(context).pushReplacement(
|
||||||
MaterialPageRoute<void>(
|
MaterialPageRoute<void>(
|
||||||
builder: (_) => GenerateResultScreen(
|
builder: (_) => GenerateResultScreen(
|
||||||
@ -74,46 +82,18 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
} else if (_isFailure(_status)) {
|
return;
|
||||||
setState(() => _error ??= 'Task failed ($_status)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _hasUsableResult(String? url) {
|
if (ProgressPollSemantics.isTerminalStatus(p.status) &&
|
||||||
if (url == null || url.isEmpty) return false;
|
ProgressPollSemantics.isFailureTerminal(p.status)) {
|
||||||
return url.startsWith('http://') || url.startsWith('https://');
|
setState(() => _error ??= 'Task failed (${p.status})');
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _isTerminal(String s) {
|
|
||||||
final t = s.toLowerCase();
|
|
||||||
return t == 'success' ||
|
|
||||||
t == 'completed' ||
|
|
||||||
t == 'complete' ||
|
|
||||||
t == 'failed' ||
|
|
||||||
t == 'failure' ||
|
|
||||||
t == 'error' ||
|
|
||||||
t == 'cancelled' ||
|
|
||||||
t == 'canceled';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isSuccess(String s) {
|
|
||||||
final t = s.toLowerCase();
|
|
||||||
return t == 'success' || t == 'completed' || t == 'complete';
|
|
||||||
}
|
|
||||||
|
|
||||||
bool _isFailure(String s) {
|
|
||||||
final t = s.toLowerCase();
|
|
||||||
return t == 'failed' ||
|
|
||||||
t == 'failure' ||
|
|
||||||
t == 'error' ||
|
|
||||||
t == 'cancelled' ||
|
|
||||||
t == 'canceled';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_timer?.cancel();
|
_pollHandle?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,12 +1,23 @@
|
|||||||
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:cached_network_image/cached_network_image.dart';
|
import 'package:cached_network_image/cached_network_image.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.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:video_player/video_player.dart';
|
||||||
|
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
import '../../widgets/pencil_chrome.dart';
|
|
||||||
import '../report/report_screen.dart';
|
import '../report/report_screen.dart';
|
||||||
|
|
||||||
class GenerateResultScreen extends StatelessWidget {
|
/// Generate result: full-bleed media (clipped), top nav, bottom save + report (design `2SyyL`).
|
||||||
|
///
|
||||||
|
/// **Why match [HomeScreen]’s Scaffold:** with `extendBody: true`, Material
|
||||||
|
/// wraps the body and adjusts [MediaQueryData.padding] for the subtree (see
|
||||||
|
/// `scaffold.dart` `_BodyBuilder`), unlike the default used on the home page.
|
||||||
|
/// That diverged from full-bleed behavior; this screen keeps `extendBody` false.
|
||||||
|
class GenerateResultScreen extends StatefulWidget {
|
||||||
const GenerateResultScreen({
|
const GenerateResultScreen({
|
||||||
super.key,
|
super.key,
|
||||||
required this.taskId,
|
required this.taskId,
|
||||||
@ -16,41 +27,222 @@ class GenerateResultScreen extends StatelessWidget {
|
|||||||
final String taskId;
|
final String taskId;
|
||||||
final String resultUrl;
|
final String resultUrl;
|
||||||
|
|
||||||
bool get _hasUrl =>
|
@override
|
||||||
resultUrl.startsWith('http://') || resultUrl.startsWith('https://');
|
State<GenerateResultScreen> createState() => _GenerateResultScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GenerateResultScreenState extends State<GenerateResultScreen> {
|
||||||
|
VideoPlayerController? _video;
|
||||||
|
bool _videoInitFailed = false;
|
||||||
|
bool _saving = false;
|
||||||
|
|
||||||
|
bool get _hasUrl {
|
||||||
|
final u = widget.resultUrl.trim();
|
||||||
|
return u.startsWith('http://') || u.startsWith('https://');
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
try {
|
||||||
|
final ok = await Gal.hasAccess();
|
||||||
|
if (!ok) {
|
||||||
|
await Gal.requestAccess();
|
||||||
|
}
|
||||||
|
final uri = Uri.parse(widget.resultUrl.trim());
|
||||||
|
final res = await http.get(uri);
|
||||||
|
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||||
|
throw HttpException('HTTP ${res.statusCode}');
|
||||||
|
}
|
||||||
|
final ext = _isVideo ? _guessVideoExt(widget.resultUrl) : '.jpg';
|
||||||
|
final file = File(
|
||||||
|
'${Directory.systemTemp.path}/funymee_${widget.taskId}$ext',
|
||||||
|
);
|
||||||
|
await file.writeAsBytes(res.bodyBytes);
|
||||||
|
if (_isVideo) {
|
||||||
|
await Gal.putVideo(file.path);
|
||||||
|
} else {
|
||||||
|
await Gal.putImage(file.path);
|
||||||
|
}
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(
|
||||||
|
'Saved to Photos',
|
||||||
|
style: GoogleFonts.inter(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on GalException catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text(e.type.message)));
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(
|
||||||
|
context,
|
||||||
|
).showSnackBar(SnackBar(content: Text('Save failed: $e')));
|
||||||
|
} finally {
|
||||||
|
if (mounted) setState(() => _saving = false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void _openReport() {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => ReportScreen(taskId: widget.taskId),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||||
decoration: const BoxDecoration(
|
|
||||||
gradient: PencilTheme.yellowWhitePageGradient,
|
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||||
|
value: const SystemUiOverlayStyle(
|
||||||
|
statusBarColor: Colors.transparent,
|
||||||
|
statusBarIconBrightness: Brightness.light,
|
||||||
|
systemNavigationBarColor: Colors.black,
|
||||||
|
systemNavigationBarIconBrightness: Brightness.light,
|
||||||
),
|
),
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.black,
|
||||||
body: SafeArea(
|
resizeToAvoidBottomInset: false,
|
||||||
child: Column(
|
body: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
clipBehavior: Clip.hardEdge,
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Positioned.fill(
|
||||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
child: MediaQuery.removeViewPadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
removeBottom: true,
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
removeBottom: true,
|
||||||
|
child: ClipRect(child: _buildBackdrop()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: 140,
|
||||||
|
child: MediaQuery.removeViewPadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: MediaQuery.removePadding(
|
||||||
|
context: context,
|
||||||
|
removeTop: true,
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withValues(alpha: 0.55),
|
||||||
|
Colors.black.withValues(alpha: 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
child: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(2, 0, 14, 0),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 56,
|
height: 56,
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
PencilRoundBackButton(
|
Material(
|
||||||
onPressed: () {
|
color: Colors.transparent,
|
||||||
Navigator.of(context)
|
child: InkWell(
|
||||||
.popUntil((r) => r.isFirst);
|
onTap: () => Navigator.of(context).pop(),
|
||||||
},
|
borderRadius: BorderRadius.circular(14),
|
||||||
|
child: const SizedBox(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
child: Icon(
|
||||||
|
Icons.chevron_left_rounded,
|
||||||
|
size: 26,
|
||||||
|
color: Colors.white,
|
||||||
|
shadows: [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 8,
|
||||||
|
color: Color(0x99000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Text(
|
child: Text(
|
||||||
'Done',
|
'Save',
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
fontStyle: FontStyle.italic,
|
fontStyle: FontStyle.italic,
|
||||||
color: PencilTheme.stone900,
|
color: Colors.white,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 8,
|
||||||
|
color: Color(0x66000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -60,50 +252,264 @@ class GenerateResultScreen extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Expanded(
|
|
||||||
child: ListView(
|
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
children: [
|
|
||||||
if (_hasUrl)
|
|
||||||
ClipRRect(
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: AspectRatio(
|
|
||||||
aspectRatio: 3 / 4,
|
|
||||||
child: CachedNetworkImage(
|
|
||||||
imageUrl: resultUrl,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
progressIndicatorBuilder: (_, _, _) => const Center(
|
|
||||||
child: CircularProgressIndicator(),
|
|
||||||
),
|
|
||||||
errorWidget: (_, _, _) =>
|
|
||||||
const Icon(Icons.broken_image),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)
|
Align(
|
||||||
else
|
alignment: Alignment.bottomCenter,
|
||||||
Text(
|
child: _ResultBottomBar(
|
||||||
'The result is not ready yet. Check History later.\nTask: $taskId',
|
bottomInset: bottomInset,
|
||||||
style: GoogleFonts.inter(color: PencilTheme.stone600),
|
saving: _saving,
|
||||||
|
onSave: _saveToGallery,
|
||||||
|
onReport: _openReport,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
|
||||||
OutlinedButton.icon(
|
|
||||||
onPressed: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => ReportScreen(taskId: taskId),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildBackdrop() {
|
||||||
|
if (!_hasUrl) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
child: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
'The result is not ready yet. Check History later.\nTask: ${widget.taskId}',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
color: Colors.white70,
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_isVideo) {
|
||||||
|
if (_videoInitFailed) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.videocam_off_outlined,
|
||||||
|
size: 48,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
},
|
},
|
||||||
icon: const Icon(Icons.flag_outlined),
|
);
|
||||||
label: const Text('Report / feedback'),
|
}
|
||||||
),
|
return SizedBox.expand(
|
||||||
],
|
child: CachedNetworkImage(
|
||||||
|
imageUrl: widget.resultUrl.trim(),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
width: double.infinity,
|
||||||
|
height: double.infinity,
|
||||||
|
progressIndicatorBuilder: (_, _, _) => const ColoredBox(
|
||||||
|
color: Colors.black,
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(color: Colors.white54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
errorWidget: (_, _, _) => ColoredBox(
|
||||||
|
color: PencilTheme.stone900,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(Icons.broken_image, size: 48, color: Colors.white54),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Bottom actions: gold CTA + hint + report on transparent background (no white bar).
|
||||||
|
class _ResultBottomBar extends StatelessWidget {
|
||||||
|
const _ResultBottomBar({
|
||||||
|
required this.bottomInset,
|
||||||
|
required this.saving,
|
||||||
|
required this.onSave,
|
||||||
|
required this.onReport,
|
||||||
|
});
|
||||||
|
|
||||||
|
final double bottomInset;
|
||||||
|
final bool saving;
|
||||||
|
final VoidCallback onSave;
|
||||||
|
final VoidCallback onReport;
|
||||||
|
|
||||||
|
static const _goldGradient = LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [Color(0xFFEAB308), Color(0xFFCA8A04)],
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 16, 20, 34 + bottomInset),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: saving ? null : onSave,
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
child: Ink(
|
||||||
|
height: 54,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
borderRadius: BorderRadius.circular(999),
|
||||||
|
gradient: _goldGradient,
|
||||||
|
boxShadow: const [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color(0x40B45309),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Center(
|
||||||
|
child: saving
|
||||||
|
? const SizedBox(
|
||||||
|
width: 22,
|
||||||
|
height: 22,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
'Save to Photos',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onReport,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.flag_outlined,
|
||||||
|
size: 18,
|
||||||
|
color: Colors.white.withValues(alpha: 0.88),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Text(
|
||||||
|
'Report',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white.withValues(alpha: 0.88),
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 6,
|
||||||
|
color: Color(0x80000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _urlLooksLikeVideo(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 _guessVideoExt(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';
|
||||||
|
}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
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: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/user/user_state.dart';
|
import '../../core/user/user_state.dart';
|
||||||
@ -28,9 +31,13 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
final _picker = ImagePicker();
|
final _picker = ImagePicker();
|
||||||
File? _picked;
|
File? _picked;
|
||||||
File? _picked2;
|
File? _picked2;
|
||||||
String _heatmap = '720p';
|
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
||||||
|
String _outputSize = '720p';
|
||||||
bool _busy = false;
|
bool _busy = false;
|
||||||
|
|
||||||
|
VideoPlayerController? _previewVideo;
|
||||||
|
bool _previewVideoFailed = false;
|
||||||
|
|
||||||
static const double _slotW = 112;
|
static const double _slotW = 112;
|
||||||
static const double _slotH = 108;
|
static const double _slotH = 108;
|
||||||
static const double _previewH = 359;
|
static const double _previewH = 359;
|
||||||
@ -42,6 +49,35 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
/// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。
|
/// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。
|
||||||
bool get _needTwoImages => widget.template?.imgNeed == 2;
|
bool get _needTwoImages => widget.template?.imgNeed == 2;
|
||||||
|
|
||||||
|
/// 与 [app_client] `ImageApi.createTask` 的 `congregation` 一致:值为 [ExtConfigItem.templateName],
|
||||||
|
/// 缺省(纯本地 extConfig)时用 [ExtConfigItem.title];`BananaTask` 不传。
|
||||||
|
String? get _templateNameForCreateTask {
|
||||||
|
final tpl = widget.template;
|
||||||
|
if (tpl == null) return null;
|
||||||
|
final tn = tpl.templateName?.trim();
|
||||||
|
final raw = (tn != null && tn.isNotEmpty ? tn : tpl.title.trim());
|
||||||
|
if (raw.isEmpty || raw == 'BananaTask') return null;
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任务类型:FunyMee 文档/换皮为逻辑 `taskType`(V2 `liaison`);优先 [ExtConfigItem.taskType],再 [params]/[detail]。
|
||||||
|
String? get _taskTypeForCreateTask {
|
||||||
|
final tt = widget.template?.taskType?.trim();
|
||||||
|
if (tt != null && tt.isNotEmpty) return tt;
|
||||||
|
final p = widget.template?.params?.trim();
|
||||||
|
if (p != null && p.isNotEmpty) return p;
|
||||||
|
final d = widget.template?.detail?.trim();
|
||||||
|
return (d != null && d.isNotEmpty) ? d : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 对应 `ext`(profile):仅当 params 与 detail 同时存在时传 detail,避免与 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;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickSlot(int slot) async {
|
Future<void> _pickSlot(int slot) async {
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
final source = await _showPickImageSourceSheet(context);
|
final source = await _showPickImageSourceSheet(context);
|
||||||
@ -174,8 +210,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
sourceFile1: _picked!,
|
sourceFile1: _picked!,
|
||||||
sourceFile2: _picked2!,
|
sourceFile2: _picked2!,
|
||||||
userId: uid,
|
userId: uid,
|
||||||
heatmap: _heatmap,
|
size: _outputSize,
|
||||||
cipher: '',
|
taskType: _taskTypeForCreateTask,
|
||||||
|
templateName: _templateNameForCreateTask,
|
||||||
|
ext: _extForCreateTask,
|
||||||
compressFirst: true,
|
compressFirst: true,
|
||||||
compressOptions: const CompressImageForUploadOptions(
|
compressOptions: const CompressImageForUploadOptions(
|
||||||
maxSide: 1024,
|
maxSide: 1024,
|
||||||
@ -187,8 +225,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
result = await ImagePresignedUploadCreateTaskFlow.run(
|
result = await ImagePresignedUploadCreateTaskFlow.run(
|
||||||
sourceFile: _primaryFile!,
|
sourceFile: _primaryFile!,
|
||||||
userId: uid,
|
userId: uid,
|
||||||
heatmap: _heatmap,
|
size: _outputSize,
|
||||||
cipher: '',
|
taskType: _taskTypeForCreateTask,
|
||||||
|
templateName: _templateNameForCreateTask,
|
||||||
|
ext: _extForCreateTask,
|
||||||
compressFirst: true,
|
compressFirst: true,
|
||||||
compressOptions: const CompressImageForUploadOptions(
|
compressOptions: const CompressImageForUploadOptions(
|
||||||
maxSide: 1024,
|
maxSide: 1024,
|
||||||
@ -231,9 +271,105 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 与当前 [_outputSize] 一致;优先 [ExtConfigItem.cost480p] / [ExtConfigItem.cost720p],
|
||||||
|
/// 否则用 [ExtConfigItem.cost] 作 720p,480p 按半档估算。
|
||||||
|
/// 与 [HomeScreen] `_HomeItemVideoBackground`:优先 [ExtConfigItem.videoUrl],否则用 `image`。
|
||||||
|
String? _previewPlayUrl(ExtConfigItem? t) {
|
||||||
|
if (t == null) return null;
|
||||||
|
final v = t.videoUrl?.trim();
|
||||||
|
if (v != null && v.isNotEmpty) return v;
|
||||||
|
final u = t.image.trim();
|
||||||
|
return u.isEmpty ? null : u;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get _previewIsVideo {
|
||||||
|
final u = _previewPlayUrl(widget.template);
|
||||||
|
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();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
int get _estimatedCost {
|
int get _estimatedCost {
|
||||||
final c = widget.template?.cost ?? 0;
|
const fallback = 20;
|
||||||
return c > 0 ? c : 20;
|
final t = widget.template;
|
||||||
|
if (t == null) return fallback;
|
||||||
|
|
||||||
|
int effective720() {
|
||||||
|
if (t.cost720p != null && t.cost720p! > 0) return t.cost720p!;
|
||||||
|
if (t.cost > 0) return t.cost;
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_outputSize == '480p') {
|
||||||
|
if (t.cost480p != null && t.cost480p! > 0) return t.cost480p!;
|
||||||
|
final h = effective720();
|
||||||
|
return max(1, (h / 2).round());
|
||||||
|
}
|
||||||
|
final c = effective720();
|
||||||
|
return c > 0 ? c : fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -305,14 +441,14 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
children: [
|
children: [
|
||||||
_resoChip(
|
_resoChip(
|
||||||
'480p',
|
'480p',
|
||||||
_heatmap == '480p',
|
_outputSize == '480p',
|
||||||
() => setState(() => _heatmap = '480p'),
|
() => setState(() => _outputSize = '480p'),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
_resoChip(
|
_resoChip(
|
||||||
'720p',
|
'720p',
|
||||||
_heatmap == '720p',
|
_outputSize == '720p',
|
||||||
() => setState(() => _heatmap = '720p'),
|
() => setState(() => _outputSize = '720p'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -458,7 +594,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
children: [
|
children: [
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(innerR),
|
borderRadius: BorderRadius.circular(innerR),
|
||||||
child: _buildPreviewImageLayer(url, fix),
|
child: _buildPreviewMediaLayer(url, fix),
|
||||||
),
|
),
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
@ -479,7 +615,60 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildPreviewImageLayer(String url, String? fix) {
|
/// 视频预览:与 [GenerateResultScreen] / 首页背景一致,循环播放;失败则回退静态图。
|
||||||
|
Widget _buildPreviewMediaLayer(String url, String? fix) {
|
||||||
|
if (_previewIsVideo) {
|
||||||
|
if (_previewVideoFailed) {
|
||||||
|
return _buildPreviewStaticImageLayer(url, fix);
|
||||||
|
}
|
||||||
|
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 _buildPreviewStaticImageLayer(url, fix);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPreviewStaticImageLayer(String url, String? fix) {
|
||||||
if (url.isNotEmpty) {
|
if (url.isNotEmpty) {
|
||||||
return CachedNetworkImage(
|
return CachedNetworkImage(
|
||||||
imageUrl: url,
|
imageUrl: url,
|
||||||
@ -669,3 +858,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _urlLooksLikeVideo(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));
|
||||||
|
}
|
||||||
|
|||||||
@ -3,9 +3,10 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
|
import '../../core/auth/auth_service.dart';
|
||||||
import '../../design/pencil_theme.dart';
|
import '../../design/pencil_theme.dart';
|
||||||
|
|
||||||
/// WBRp4「Credit Record」内容区样式。
|
/// WBRp4「Credit Record」内容区 — 对齐 `funymee_home.pen` [listCr](ez9wP)。
|
||||||
class CreditRecordTab extends StatefulWidget {
|
class CreditRecordTab extends StatefulWidget {
|
||||||
const CreditRecordTab({super.key});
|
const CreditRecordTab({super.key});
|
||||||
|
|
||||||
@ -17,11 +18,27 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<CreditRecordItem> _records = [];
|
List<CreditRecordItem> _records = [];
|
||||||
|
VoidCallback? _cancelLoginWait;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_load();
|
_cancelLoginWait = AuthService.whenLoginSucceeded(
|
||||||
|
onReady: _load,
|
||||||
|
onFailed: () {
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Sign in failed';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cancelLoginWait?.call();
|
||||||
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _load() async {
|
Future<void> _load() async {
|
||||||
@ -44,18 +61,20 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
String _formatTime(int? t) {
|
String _formatDate(int? t) {
|
||||||
if (t == null) return '—';
|
if (t == null) return '—';
|
||||||
var ms = t;
|
var ms = t;
|
||||||
if (t < 2000000000) ms = t * 1000;
|
if (t < 2000000000) ms = t * 1000;
|
||||||
final dt = DateTime.fromMillisecondsSinceEpoch(ms);
|
final dt = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||||
return DateFormat('yyyy-MM-dd HH:mm').format(dt);
|
return DateFormat('yyyy/MM/dd').format(dt);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: PencilTheme.underlineGold),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (_error != null) {
|
if (_error != null) {
|
||||||
return Center(
|
return Center(
|
||||||
@ -70,55 +89,103 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
|||||||
}
|
}
|
||||||
if (_records.isEmpty) {
|
if (_records.isEmpty) {
|
||||||
return Center(
|
return Center(
|
||||||
child: Text('No records.',
|
child: Text(
|
||||||
style: GoogleFonts.inter(color: PencilTheme.inkSoft)),
|
'No records.',
|
||||||
|
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return RefreshIndicator(
|
return RefreshIndicator(
|
||||||
|
color: PencilTheme.underlineGold,
|
||||||
onRefresh: _load,
|
onRefresh: _load,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||||
itemCount: _records.length,
|
itemCount: _records.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final r = _records[i];
|
final r = _records[i];
|
||||||
final c = r.credits ?? 0;
|
final c = r.credits ?? 0;
|
||||||
return Container(
|
return _CreditRecordRowCard(
|
||||||
padding: const EdgeInsets.all(16),
|
amountLabel: '${c > 0 ? '+' : ''}$c',
|
||||||
decoration: BoxDecoration(
|
dateLabel: _formatDate(r.createTime),
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
border: Border.all(color: PencilTheme.genHintBorder),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: const Color(0x30CA8A04),
|
|
||||||
blurRadius: 20,
|
|
||||||
offset: const Offset(0, 6),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'${c > 0 ? '+' : ''}$c credits',
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: PencilTheme.stone900,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatTime(r.createTime),
|
|
||||||
style: GoogleFonts.inter(
|
|
||||||
fontSize: 13,
|
|
||||||
color: PencilTheme.stone600,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 单条流水:72 高、圆角 10、黄橙渐变 + 纹理,左 sparkles + 金额,右日期。
|
||||||
|
class _CreditRecordRowCard extends StatelessWidget {
|
||||||
|
const _CreditRecordRowCard({
|
||||||
|
required this.amountLabel,
|
||||||
|
required this.dateLabel,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String amountLabel;
|
||||||
|
final String dateLabel;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
child: SizedBox(
|
||||||
|
height: 72,
|
||||||
|
child: Stack(
|
||||||
|
fit: StackFit.expand,
|
||||||
|
children: [
|
||||||
|
Container(decoration: const BoxDecoration(gradient: PencilTheme.creditRecordRowGradient)),
|
||||||
|
Positioned.fill(
|
||||||
|
child: IgnorePointer(
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.45,
|
||||||
|
child: Image.asset(
|
||||||
|
'assets/images/card_texture_glow_lines.png',
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, _, _) => const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 18),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Icon(
|
||||||
|
Icons.auto_awesome,
|
||||||
|
size: 22,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 10),
|
||||||
|
Text(
|
||||||
|
amountLabel,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
Text(
|
||||||
|
dateLabel,
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -3,14 +3,28 @@ 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/auth/auth_service.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/generate_result_screen.dart';
|
||||||
import 'credit_record_tab.dart';
|
import 'credit_record_tab.dart';
|
||||||
import 'widgets/history_grid_card.dart';
|
import 'widgets/history_grid_card.dart';
|
||||||
|
|
||||||
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
|
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
|
||||||
class HistoryScreen extends StatefulWidget {
|
class HistoryScreen extends StatefulWidget {
|
||||||
const HistoryScreen({super.key});
|
const HistoryScreen({
|
||||||
|
super.key,
|
||||||
|
this.isRootTab = false,
|
||||||
|
this.isTabSelected = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
/// When true (e.g. bottom tab), hide back button; when pushed, show back.
|
||||||
|
final bool isRootTab;
|
||||||
|
|
||||||
|
/// Bottom shell: only `true` when the History tab is selected — defers `my-tasks` load.
|
||||||
|
/// Pushed routes should default `true` so load runs as before.
|
||||||
|
final bool isTabSelected;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||||
@ -18,14 +32,47 @@ class HistoryScreen extends StatefulWidget {
|
|||||||
|
|
||||||
class _HistoryScreenState extends State<HistoryScreen> {
|
class _HistoryScreenState extends State<HistoryScreen> {
|
||||||
int _tab = 0;
|
int _tab = 0;
|
||||||
bool _loading = true;
|
bool _loading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
List<MyTaskItem> _items = [];
|
List<MyTaskItem> _items = [];
|
||||||
Map<String, String> _localCovers = {};
|
Map<String, String> _localCovers = {};
|
||||||
|
VoidCallback? _cancelLoginWait;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
_cancelLoginWait = AuthService.whenLoginSucceeded(
|
||||||
|
onReady: _tryLoadTasks,
|
||||||
|
onFailed: () {
|
||||||
|
if (!mounted || !widget.isTabSelected) return;
|
||||||
|
setState(() {
|
||||||
|
_loading = false;
|
||||||
|
_error = 'Sign in failed';
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void didUpdateWidget(HistoryScreen oldWidget) {
|
||||||
|
super.didUpdateWidget(oldWidget);
|
||||||
|
if (!oldWidget.isTabSelected && widget.isTabSelected) {
|
||||||
|
_tryLoadTasks();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_cancelLoginWait?.call();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /v1/image/my-tasks` only when this tab is visible and login succeeded.
|
||||||
|
void _tryLoadTasks() {
|
||||||
|
if (!widget.isTabSelected) return;
|
||||||
|
if (!AuthService.isLoginComplete.value) return;
|
||||||
|
final uid = UserState.userId.value;
|
||||||
|
if (uid == null || uid.isEmpty) return;
|
||||||
_load();
|
_load();
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,6 +124,9 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
const EdgeInsets.symmetric(horizontal: 12),
|
const EdgeInsets.symmetric(horizontal: 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
|
if (widget.isRootTab)
|
||||||
|
const SizedBox(width: 44)
|
||||||
|
else
|
||||||
PencilRoundBackButton(
|
PencilRoundBackButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
),
|
),
|
||||||
@ -140,6 +190,26 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _myHistoryBody() {
|
Widget _myHistoryBody() {
|
||||||
|
if (!widget.isTabSelected) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
if (!AuthService.isLoginComplete.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
final uid = UserState.userId.value;
|
||||||
|
if (uid == null || uid.isEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
_error ?? 'Sign in failed',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@ -187,6 +257,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
|||||||
item: t,
|
item: t,
|
||||||
localCoverPath:
|
localCoverPath:
|
||||||
id.isEmpty ? null : _localCovers[id],
|
id.isEmpty ? null : _localCovers[id],
|
||||||
|
onTap: () {
|
||||||
|
Navigator.of(context).push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
builder: (_) => GenerateResultScreen(
|
||||||
|
taskId: id,
|
||||||
|
resultUrl: t.resultUrl?.trim() ?? '',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
onDownload: () {},
|
onDownload: () {},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|||||||
@ -4,26 +4,33 @@ 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:google_fonts/google_fonts.dart';
|
import 'package:google_fonts/google_fonts.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
|
||||||
import '../../../design/pencil_theme.dart';
|
import '../../../design/pencil_theme.dart';
|
||||||
|
|
||||||
|
final _historyCardDateFormat = DateFormat('MMM d, yyyy');
|
||||||
|
|
||||||
/// WBRp4 单张卡片:171×182 比例,圆角 20,Download pill。
|
/// WBRp4 单张卡片:171×182 比例,圆角 20,Download pill。
|
||||||
|
///
|
||||||
|
/// [MyTaskItem.resultUrl] 为生成结果地址(逻辑字段 `imgUrl`,线网常为 `boost`),可能是图片或视频。
|
||||||
class HistoryGridCard extends StatelessWidget {
|
class HistoryGridCard extends StatelessWidget {
|
||||||
const HistoryGridCard({
|
const HistoryGridCard({
|
||||||
super.key,
|
super.key,
|
||||||
required this.item,
|
required this.item,
|
||||||
this.localCoverPath,
|
this.localCoverPath,
|
||||||
|
this.onTap,
|
||||||
this.onDownload,
|
this.onDownload,
|
||||||
});
|
});
|
||||||
|
|
||||||
final MyTaskItem item;
|
final MyTaskItem item;
|
||||||
final String? localCoverPath;
|
final String? localCoverPath;
|
||||||
|
final VoidCallback? onTap;
|
||||||
final VoidCallback? onDownload;
|
final VoidCallback? onDownload;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final url = item.resultUrl?.trim() ?? '';
|
final url = item.resultUrl?.trim() ?? '';
|
||||||
final created = item.createTime ?? '—';
|
final dateLabel = _formatCardDate(item.createTime);
|
||||||
final remainder = _remainderLabel(item.createTime);
|
final remainder = _remainderLabel(item.createTime);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
@ -33,6 +40,9 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
return SizedBox(
|
return SizedBox(
|
||||||
width: w,
|
width: w,
|
||||||
height: h,
|
height: h,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@ -43,44 +53,81 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
height: h,
|
height: h,
|
||||||
child: ClipRRect(
|
child: ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
child: url.isNotEmpty
|
child: _HistoryThumb(
|
||||||
? CachedNetworkImage(imageUrl: url, fit: BoxFit.cover)
|
networkUrl: url.isNotEmpty ? url : null,
|
||||||
: localCoverPath != null
|
localPath: localCoverPath,
|
||||||
? Image.file(File(localCoverPath!), fit: BoxFit.cover)
|
),
|
||||||
: Container(color: PencilTheme.cardThumbBg),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
left: 8,
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
|
height: 64,
|
||||||
|
child: ClipRRect(
|
||||||
|
borderRadius: const BorderRadius.vertical(
|
||||||
|
top: Radius.circular(20),
|
||||||
|
),
|
||||||
|
child: DecoratedBox(
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
|
end: Alignment.bottomCenter,
|
||||||
|
colors: [
|
||||||
|
Colors.black.withValues(alpha: 0.55),
|
||||||
|
Colors.black.withValues(alpha: 0),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned(
|
||||||
|
left: 10,
|
||||||
top: 10,
|
top: 10,
|
||||||
right: 8,
|
right: 10,
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
created,
|
dateLabel,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: PencilTheme.inkMuted,
|
color: Colors.white.withValues(alpha: 0.95),
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 4,
|
||||||
|
color: Color(0x40000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
remainder,
|
remainder,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 9,
|
fontSize: 9,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: PencilTheme.underlineGold,
|
color: PencilTheme.underlineGold,
|
||||||
|
shadows: const [
|
||||||
|
Shadow(
|
||||||
|
blurRadius: 4,
|
||||||
|
color: Color(0x40000000),
|
||||||
|
offset: Offset(0, 1),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Positioned(
|
Positioned(
|
||||||
right: 0,
|
right: 8,
|
||||||
bottom: 0,
|
bottom: 8,
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
@ -115,23 +162,92 @@ class HistoryGridCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _remainderLabel(String? createTimeRaw) {
|
bool _urlLooksLikeVideo(String url) {
|
||||||
if (createTimeRaw == null || createTimeRaw.isEmpty) return '—';
|
if (url.isEmpty) return false;
|
||||||
DateTime? created;
|
final lower = url.toLowerCase();
|
||||||
final asInt = int.tryParse(createTimeRaw);
|
const hints = ['.mp4', '.m3u8', '.webm', '.mov', '.mkv', '.avi'];
|
||||||
|
return hints.any((h) => lower.contains(h));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 图片:网络图;视频:仅封面(优先本地上传缩略图),不播放视频。
|
||||||
|
class _HistoryThumb extends StatelessWidget {
|
||||||
|
const _HistoryThumb({
|
||||||
|
this.networkUrl,
|
||||||
|
this.localPath,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String? networkUrl;
|
||||||
|
final String? localPath;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final net = networkUrl?.trim() ?? '';
|
||||||
|
if (net.isNotEmpty) {
|
||||||
|
final uri = Uri.tryParse(net);
|
||||||
|
if (uri != null &&
|
||||||
|
(uri.isScheme('http') || uri.isScheme('https'))) {
|
||||||
|
if (_urlLooksLikeVideo(net)) {
|
||||||
|
if (localPath != null && localPath!.isNotEmpty) {
|
||||||
|
return Image.file(File(localPath!), fit: BoxFit.cover);
|
||||||
|
}
|
||||||
|
return const _VideoCoverPlaceholder();
|
||||||
|
}
|
||||||
|
return CachedNetworkImage(imageUrl: net, fit: BoxFit.cover);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (localPath != null && localPath!.isNotEmpty) {
|
||||||
|
return Image.file(File(localPath!), fit: BoxFit.cover);
|
||||||
|
}
|
||||||
|
return Container(color: PencilTheme.cardThumbBg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 无本地封面时的视频任务占位(不拉起解码器)。
|
||||||
|
class _VideoCoverPlaceholder extends StatelessWidget {
|
||||||
|
const _VideoCoverPlaceholder();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ColoredBox(
|
||||||
|
color: PencilTheme.cardThumbBg,
|
||||||
|
child: Center(
|
||||||
|
child: Icon(
|
||||||
|
Icons.videocam_outlined,
|
||||||
|
size: 40,
|
||||||
|
color: PencilTheme.inkSoft.withValues(alpha: 0.45),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 与 [MyTaskItem.createTime] 一致:毫秒时间戳、秒级时间戳或 ISO 字符串。
|
||||||
|
DateTime? _parseCreateTime(String? raw) {
|
||||||
|
if (raw == null || raw.isEmpty) return null;
|
||||||
|
final asInt = int.tryParse(raw.trim());
|
||||||
if (asInt != null) {
|
if (asInt != null) {
|
||||||
var ms = asInt;
|
var ms = asInt;
|
||||||
if (asInt < 2000000000) ms = asInt * 1000;
|
if (asInt < 2000000000) ms = asInt * 1000;
|
||||||
created = DateTime.fromMillisecondsSinceEpoch(ms);
|
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||||
} else {
|
|
||||||
created = DateTime.tryParse(createTimeRaw);
|
|
||||||
}
|
}
|
||||||
|
return DateTime.tryParse(raw.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatCardDate(String? raw) {
|
||||||
|
final dt = _parseCreateTime(raw);
|
||||||
|
if (dt == null) return '—';
|
||||||
|
return _historyCardDateFormat.format(dt);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _remainderLabel(String? createTimeRaw) {
|
||||||
|
final created = _parseCreateTime(createTimeRaw);
|
||||||
if (created == null) return '—';
|
if (created == null) return '—';
|
||||||
final deadline = created.add(const Duration(hours: 24));
|
final deadline = created.add(const Duration(hours: 24));
|
||||||
final left = deadline.difference(DateTime.now());
|
final left = deadline.difference(DateTime.now());
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
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';
|
||||||
@ -12,8 +13,21 @@ 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/generate_screen.dart';
|
import '../generate/generate_screen.dart';
|
||||||
import '../history/history_screen.dart';
|
|
||||||
import '../profile/profile_screen.dart';
|
/// 首页 Create Now 上方展示的预估积分:**480p 档**(与 [GenerateScreen] 选 480p 时一致)。
|
||||||
|
int _homeCostDisplay480p(ExtConfigItem? t) {
|
||||||
|
if (t == null) return 0;
|
||||||
|
if (t.cost480p != null && t.cost480p! > 0) return t.cost480p!;
|
||||||
|
int effective720() {
|
||||||
|
if (t.cost720p != null && t.cost720p! > 0) return t.cost720p!;
|
||||||
|
if (t.cost > 0) return t.cost;
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final h = effective720();
|
||||||
|
if (h <= 0) return 0;
|
||||||
|
return max(1, (h / 2).round());
|
||||||
|
}
|
||||||
|
|
||||||
/// 首页横向 [PageView] 的一页:对应某个顶部分类 [tabIndex],[item] 为空表示该分类暂无模板占位。
|
/// 首页横向 [PageView] 的一页:对应某个顶部分类 [tabIndex],[item] 为空表示该分类暂无模板占位。
|
||||||
class _FlatHomePage {
|
class _FlatHomePage {
|
||||||
@ -356,7 +370,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
padding: const EdgeInsets.only(
|
padding: const EdgeInsets.only(
|
||||||
left: 16,
|
left: 16,
|
||||||
right: 16,
|
right: 16,
|
||||||
bottom: 34,
|
bottom: 16,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
@ -364,20 +378,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
SizedBox(
|
SizedBox(
|
||||||
height: 56,
|
height: 56,
|
||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
|
||||||
PencilGlassSquareButton(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => const HistoryScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.history_rounded,
|
|
||||||
color: Colors.white, size: 22),
|
|
||||||
),
|
|
||||||
Row(
|
|
||||||
children: [
|
children: [
|
||||||
ValueListenableBuilder<int>(
|
ValueListenableBuilder<int>(
|
||||||
valueListenable: UserState.credits,
|
valueListenable: UserState.credits,
|
||||||
@ -387,20 +388,6 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(width: 10),
|
|
||||||
PencilGlassSquareButton(
|
|
||||||
onTap: () {
|
|
||||||
Navigator.of(context).push(
|
|
||||||
MaterialPageRoute<void>(
|
|
||||||
builder: (_) => const ProfileScreen(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
child: const Icon(Icons.settings_rounded,
|
|
||||||
color: Colors.white, size: 22),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -661,7 +648,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
|||||||
final template = flat.isEmpty
|
final template = flat.isEmpty
|
||||||
? null
|
? null
|
||||||
: flat[safe].item;
|
: flat[safe].item;
|
||||||
final cost = template?.cost ?? 0;
|
final cost = _homeCostDisplay480p(template);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
|||||||
@ -14,7 +14,10 @@ import 'delete_account_flow.dart';
|
|||||||
|
|
||||||
/// `5J8Po` 个人中心。
|
/// `5J8Po` 个人中心。
|
||||||
class ProfileScreen extends StatefulWidget {
|
class ProfileScreen extends StatefulWidget {
|
||||||
const ProfileScreen({super.key});
|
const ProfileScreen({super.key, this.isRootTab = false});
|
||||||
|
|
||||||
|
/// When true (e.g. bottom tab), hide close; when pushed, show close.
|
||||||
|
final bool isRootTab;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||||
@ -47,7 +50,18 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
|||||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 56,
|
height: 56,
|
||||||
child: Align(
|
child: widget.isRootTab
|
||||||
|
? Center(
|
||||||
|
child: Text(
|
||||||
|
'Profile',
|
||||||
|
style: GoogleFonts.inter(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: PencilTheme.ink,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: PencilRoundCloseButton(
|
child: PencilRoundCloseButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(),
|
||||||
|
|||||||
@ -459,9 +459,11 @@ class _ProductCard extends StatelessWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final rawTitle = item.title;
|
final rawTitle = item.title;
|
||||||
final title = (rawTitle != null && rawTitle.trim().isNotEmpty)
|
final creditsTopLabel = item.credits != null
|
||||||
? rawTitle
|
? 'Credits:${item.credits}'
|
||||||
: 'Credit';
|
: (rawTitle != null && rawTitle.trim().isNotEmpty)
|
||||||
|
? 'Credits:${rawTitle.trim()}'
|
||||||
|
: 'Credits:—';
|
||||||
final actual = item.actualAmount ?? '—';
|
final actual = item.actualAmount ?? '—';
|
||||||
final origin = item.originAmount;
|
final origin = item.originAmount;
|
||||||
final bonus = item.bonus;
|
final bonus = item.bonus;
|
||||||
@ -474,7 +476,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
onTap: paying ? null : () => onTap(item, index),
|
onTap: paying ? null : () => onTap(item, index),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
child: SizedBox(
|
child: SizedBox(
|
||||||
height: 125,
|
height: 130,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
clipBehavior: Clip.none,
|
clipBehavior: Clip.none,
|
||||||
children: [
|
children: [
|
||||||
@ -484,7 +486,7 @@ class _ProductCard extends StatelessWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
title,
|
creditsTopLabel,
|
||||||
style: GoogleFonts.inter(
|
style: GoogleFonts.inter(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
|
|||||||
@ -1,13 +1,55 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../history/history_screen.dart';
|
||||||
import '../home/home_screen.dart';
|
import '../home/home_screen.dart';
|
||||||
|
import '../profile/profile_screen.dart';
|
||||||
|
|
||||||
/// 根壳:设计稿首页无底部 Tab,子页由首页按钮/路由 [Navigator.push] 进入。
|
/// Root shell: bottom tabs **Home**, **History**, **Profile** (English labels).
|
||||||
class MainScreen extends StatelessWidget {
|
class MainScreen extends StatefulWidget {
|
||||||
const MainScreen({super.key});
|
const MainScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MainScreen> createState() => _MainScreenState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MainScreenState extends State<MainScreen> {
|
||||||
|
int _index = 0;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return const HomeScreen();
|
return Scaffold(
|
||||||
|
body: IndexedStack(
|
||||||
|
index: _index,
|
||||||
|
children: [
|
||||||
|
const HomeScreen(),
|
||||||
|
HistoryScreen(
|
||||||
|
isRootTab: true,
|
||||||
|
isTabSelected: _index == 1,
|
||||||
|
),
|
||||||
|
const ProfileScreen(isRootTab: true),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
bottomNavigationBar: NavigationBar(
|
||||||
|
selectedIndex: _index,
|
||||||
|
onDestinationSelected: (i) => setState(() => _index = i),
|
||||||
|
destinations: const [
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.home_outlined),
|
||||||
|
selectedIcon: Icon(Icons.home_rounded),
|
||||||
|
label: 'Home',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.history_outlined),
|
||||||
|
selectedIcon: Icon(Icons.history_rounded),
|
||||||
|
label: 'History',
|
||||||
|
),
|
||||||
|
NavigationDestination(
|
||||||
|
icon: Icon(Icons.person_outline_rounded),
|
||||||
|
selectedIcon: Icon(Icons.person_rounded),
|
||||||
|
label: 'Profile',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import Foundation
|
|||||||
|
|
||||||
import device_info_plus
|
import device_info_plus
|
||||||
import file_selector_macos
|
import file_selector_macos
|
||||||
|
import gal
|
||||||
import in_app_purchase_storekit
|
import in_app_purchase_storekit
|
||||||
import package_info_plus
|
import package_info_plus
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
@ -17,6 +18,7 @@ import webview_flutter_wkwebview
|
|||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||||
|
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||||
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
||||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
|
|||||||
@ -295,6 +295,14 @@ packages:
|
|||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
version: "0.0.0"
|
||||||
|
gal:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: gal
|
||||||
|
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.3.2"
|
||||||
glob:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|||||||
@ -28,6 +28,7 @@ dependencies:
|
|||||||
google_fonts: ^6.2.1
|
google_fonts: ^6.2.1
|
||||||
package_info_plus: ^8.1.2
|
package_info_plus: ^8.1.2
|
||||||
webview_flutter: ^4.13.1
|
webview_flutter: ^4.13.1
|
||||||
|
gal: ^2.3.2
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|||||||
@ -7,8 +7,11 @@
|
|||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
#include <file_selector_windows/file_selector_windows.h>
|
#include <file_selector_windows/file_selector_windows.h>
|
||||||
|
#include <gal/gal_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
FileSelectorWindowsRegisterWithRegistrar(
|
FileSelectorWindowsRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||||
|
GalPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
file_selector_windows
|
file_selector_windows
|
||||||
|
gal
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user