优化:细节调整
This commit is contained in:
parent
22f712c5a8
commit
8cc9fe7ce5
@ -1,5 +1,8 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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.READ_MEDIA_IMAGES" />
|
||||
<uses-permission
|
||||
@ -30,7 +33,8 @@
|
||||
|
||||
<application
|
||||
android:label="FunyMee AI"
|
||||
android:icon="@mipmap/ic_launcher">
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:requestLegacyExternalStorage="true">
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
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"],
|
||||
"imgNeed": ["img_need"],
|
||||
"cost": ["cost"],
|
||||
"cost480p": ["cost_480p", "cost480p", "cost_480"],
|
||||
"cost720p": ["cost_720p", "cost720p", "cost_720"],
|
||||
"title": ["title"],
|
||||
"params": ["params"],
|
||||
"detail": ["detail"],
|
||||
|
||||
@ -6122,4 +6122,4 @@
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -84,5 +84,7 @@
|
||||
<string>FunyMee needs camera access to take photos for generation.</string>
|
||||
<key>NSPhotoLibraryUsageDescription</key>
|
||||
<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>
|
||||
</plist>
|
||||
|
||||
@ -87,4 +87,37 @@ class AuthService {
|
||||
/// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。
|
||||
static ValueNotifier<bool> get 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 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;
|
||||
}
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:client_proxy_framework/client_proxy_framework.dart';
|
||||
@ -27,29 +26,38 @@ class GenerateProgressScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
Timer? _timer;
|
||||
ImageProgressPollHandle? _pollHandle;
|
||||
String _status = '';
|
||||
int _progress = 0;
|
||||
String? _resultUrl;
|
||||
String? _error;
|
||||
bool _finished = false;
|
||||
bool _navigated = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_poll();
|
||||
_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(
|
||||
_pollHandle = ImageProgressPoll.start(
|
||||
app: currentBackendAppType(),
|
||||
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) {
|
||||
setState(() => _error = res.msg.isNotEmpty ? res.msg : 'Progress error');
|
||||
return;
|
||||
@ -61,59 +69,31 @@ class _GenerateProgressScreenState extends State<GenerateProgressScreen> {
|
||||
_resultUrl = p.resultUrl;
|
||||
});
|
||||
|
||||
if (_isTerminal(_status) || _hasUsableResult(_resultUrl)) {
|
||||
_timer?.cancel();
|
||||
if (_isSuccess(_status) || _hasUsableResult(_resultUrl)) {
|
||||
_finished = true;
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GenerateResultScreen(
|
||||
taskId: widget.taskId,
|
||||
resultUrl: _resultUrl ?? '',
|
||||
),
|
||||
final doneSuccess = ProgressPollSemantics.isSuccessTerminal(p.status) ||
|
||||
ProgressPollSemantics.hasUsableResultUrl(p.resultUrl);
|
||||
if (doneSuccess) {
|
||||
_navigated = true;
|
||||
_pollHandle?.cancel();
|
||||
Navigator.of(context).pushReplacement(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GenerateResultScreen(
|
||||
taskId: widget.taskId,
|
||||
resultUrl: _resultUrl ?? '',
|
||||
),
|
||||
);
|
||||
} else if (_isFailure(_status)) {
|
||||
setState(() => _error ??= 'Task failed ($_status)');
|
||||
}
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bool _hasUsableResult(String? url) {
|
||||
if (url == null || url.isEmpty) return false;
|
||||
return url.startsWith('http://') || url.startsWith('https://');
|
||||
}
|
||||
|
||||
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';
|
||||
if (ProgressPollSemantics.isTerminalStatus(p.status) &&
|
||||
ProgressPollSemantics.isFailureTerminal(p.status)) {
|
||||
setState(() => _error ??= 'Task failed (${p.status})');
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_pollHandle?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
|
||||
@ -1,12 +1,23 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.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:http/http.dart' as http;
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
import '../../design/pencil_theme.dart';
|
||||
import '../../widgets/pencil_chrome.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({
|
||||
super.key,
|
||||
required this.taskId,
|
||||
@ -16,94 +27,489 @@ class GenerateResultScreen extends StatelessWidget {
|
||||
final String taskId;
|
||||
final String resultUrl;
|
||||
|
||||
bool get _hasUrl =>
|
||||
resultUrl.startsWith('http://') || resultUrl.startsWith('https://');
|
||||
@override
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: PencilTheme.yellowWhitePageGradient,
|
||||
final bottomInset = MediaQuery.paddingOf(context).bottom;
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: const SystemUiOverlayStyle(
|
||||
statusBarColor: Colors.transparent,
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
systemNavigationBarColor: Colors.black,
|
||||
systemNavigationBarIconBrightness: Brightness.light,
|
||||
),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
PencilRoundBackButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context)
|
||||
.popUntil((r) => r.isFirst);
|
||||
},
|
||||
backgroundColor: Colors.black,
|
||||
resizeToAvoidBottomInset: false,
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Positioned.fill(
|
||||
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),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Done',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: PencilTheme.stone900,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
child: SafeArea(
|
||||
bottom: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 0),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
errorWidget: (_, _, _) =>
|
||||
const Icon(Icons.broken_image),
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Text(
|
||||
'The result is not ready yet. Check History later.\nTask: $taskId',
|
||||
style: GoogleFonts.inter(color: PencilTheme.stone600),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => ReportScreen(taskId: taskId),
|
||||
Expanded(
|
||||
child: Center(
|
||||
child: Text(
|
||||
'Save',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontStyle: FontStyle.italic,
|
||||
color: Colors.white,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 8,
|
||||
color: Color(0x66000000),
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.flag_outlined),
|
||||
label: const Text('Report / feedback'),
|
||||
),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: _ResultBottomBar(
|
||||
bottomInset: bottomInset,
|
||||
saving: _saving,
|
||||
onSave: _saveToGallery,
|
||||
onReport: _openReport,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
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: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: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/user/user_state.dart';
|
||||
@ -28,9 +31,13 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
final _picker = ImagePicker();
|
||||
File? _picked;
|
||||
File? _picked2;
|
||||
String _heatmap = '720p';
|
||||
/// 对应 create-task 逻辑字段 `size`(换皮 `seminar`),如 480p / 720p。
|
||||
String _outputSize = '720p';
|
||||
bool _busy = false;
|
||||
|
||||
VideoPlayerController? _previewVideo;
|
||||
bool _previewVideoFailed = false;
|
||||
|
||||
static const double _slotW = 112;
|
||||
static const double _slotH = 108;
|
||||
static const double _previewH = 359;
|
||||
@ -42,6 +49,35 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
/// 双图:仅当扩展配置 `img_need == 2`(与 [ExtConfigItem.imgNeed] 一致)。
|
||||
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 {
|
||||
if (!mounted) return;
|
||||
final source = await _showPickImageSourceSheet(context);
|
||||
@ -174,8 +210,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
sourceFile1: _picked!,
|
||||
sourceFile2: _picked2!,
|
||||
userId: uid,
|
||||
heatmap: _heatmap,
|
||||
cipher: '',
|
||||
size: _outputSize,
|
||||
taskType: _taskTypeForCreateTask,
|
||||
templateName: _templateNameForCreateTask,
|
||||
ext: _extForCreateTask,
|
||||
compressFirst: true,
|
||||
compressOptions: const CompressImageForUploadOptions(
|
||||
maxSide: 1024,
|
||||
@ -187,8 +225,10 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
result = await ImagePresignedUploadCreateTaskFlow.run(
|
||||
sourceFile: _primaryFile!,
|
||||
userId: uid,
|
||||
heatmap: _heatmap,
|
||||
cipher: '',
|
||||
size: _outputSize,
|
||||
taskType: _taskTypeForCreateTask,
|
||||
templateName: _templateNameForCreateTask,
|
||||
ext: _extForCreateTask,
|
||||
compressFirst: true,
|
||||
compressOptions: const CompressImageForUploadOptions(
|
||||
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 {
|
||||
final c = widget.template?.cost ?? 0;
|
||||
return c > 0 ? c : 20;
|
||||
const fallback = 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
|
||||
@ -305,14 +441,14 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
children: [
|
||||
_resoChip(
|
||||
'480p',
|
||||
_heatmap == '480p',
|
||||
() => setState(() => _heatmap = '480p'),
|
||||
_outputSize == '480p',
|
||||
() => setState(() => _outputSize = '480p'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
_resoChip(
|
||||
'720p',
|
||||
_heatmap == '720p',
|
||||
() => setState(() => _heatmap = '720p'),
|
||||
_outputSize == '720p',
|
||||
() => setState(() => _outputSize = '720p'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -458,7 +594,7 @@ class _GenerateScreenState extends State<GenerateScreen> {
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(innerR),
|
||||
child: _buildPreviewImageLayer(url, fix),
|
||||
child: _buildPreviewMediaLayer(url, fix),
|
||||
),
|
||||
Positioned.fill(
|
||||
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) {
|
||||
return CachedNetworkImage(
|
||||
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:intl/intl.dart';
|
||||
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../design/pencil_theme.dart';
|
||||
|
||||
/// WBRp4「Credit Record」内容区样式。
|
||||
/// WBRp4「Credit Record」内容区 — 对齐 `funymee_home.pen` [listCr](ez9wP)。
|
||||
class CreditRecordTab extends StatefulWidget {
|
||||
const CreditRecordTab({super.key});
|
||||
|
||||
@ -17,11 +18,27 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<CreditRecordItem> _records = [];
|
||||
VoidCallback? _cancelLoginWait;
|
||||
|
||||
@override
|
||||
void 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 {
|
||||
@ -44,18 +61,20 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
||||
});
|
||||
}
|
||||
|
||||
String _formatTime(int? t) {
|
||||
String _formatDate(int? t) {
|
||||
if (t == null) return '—';
|
||||
var ms = t;
|
||||
if (t < 2000000000) ms = t * 1000;
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
return DateFormat('yyyy-MM-dd HH:mm').format(dt);
|
||||
return DateFormat('yyyy/MM/dd').format(dt);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: PencilTheme.underlineGold),
|
||||
);
|
||||
}
|
||||
if (_error != null) {
|
||||
return Center(
|
||||
@ -70,55 +89,103 @@ class _CreditRecordTabState extends State<CreditRecordTab> {
|
||||
}
|
||||
if (_records.isEmpty) {
|
||||
return Center(
|
||||
child: Text('No records.',
|
||||
style: GoogleFonts.inter(color: PencilTheme.inkSoft)),
|
||||
child: Text(
|
||||
'No records.',
|
||||
style: GoogleFonts.inter(color: PencilTheme.inkSoft),
|
||||
),
|
||||
);
|
||||
}
|
||||
return RefreshIndicator(
|
||||
color: PencilTheme.underlineGold,
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 28),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 28),
|
||||
itemCount: _records.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 8),
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (_, i) {
|
||||
final r = _records[i];
|
||||
final c = r.credits ?? 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
return _CreditRecordRowCard(
|
||||
amountLabel: '${c > 0 ? '+' : ''}$c',
|
||||
dateLabel: _formatDate(r.createTime),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 单条流水: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 '../../core/app_env.dart';
|
||||
import '../../core/auth/auth_service.dart';
|
||||
import '../../core/user/user_state.dart';
|
||||
import '../../design/pencil_theme.dart';
|
||||
import '../../widgets/pencil_chrome.dart';
|
||||
import '../generate/generate_result_screen.dart';
|
||||
import 'credit_record_tab.dart';
|
||||
import 'widgets/history_grid_card.dart';
|
||||
|
||||
/// `WBRp4` My History — 黄→白渐变、顶栏、双 Tab、24h 提示、双列卡片。
|
||||
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
|
||||
State<HistoryScreen> createState() => _HistoryScreenState();
|
||||
@ -18,14 +32,47 @@ class HistoryScreen extends StatefulWidget {
|
||||
|
||||
class _HistoryScreenState extends State<HistoryScreen> {
|
||||
int _tab = 0;
|
||||
bool _loading = true;
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
List<MyTaskItem> _items = [];
|
||||
Map<String, String> _localCovers = {};
|
||||
VoidCallback? _cancelLoginWait;
|
||||
|
||||
@override
|
||||
void 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();
|
||||
}
|
||||
|
||||
@ -77,9 +124,12 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
PencilRoundBackButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
if (widget.isRootTab)
|
||||
const SizedBox(width: 44)
|
||||
else
|
||||
PencilRoundBackButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
@ -140,6 +190,26 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
}
|
||||
|
||||
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) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@ -187,6 +257,16 @@ class _HistoryScreenState extends State<HistoryScreen> {
|
||||
item: t,
|
||||
localCoverPath:
|
||||
id.isEmpty ? null : _localCovers[id],
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => GenerateResultScreen(
|
||||
taskId: id,
|
||||
resultUrl: t.resultUrl?.trim() ?? '',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
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:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../design/pencil_theme.dart';
|
||||
|
||||
final _historyCardDateFormat = DateFormat('MMM d, yyyy');
|
||||
|
||||
/// WBRp4 单张卡片:171×182 比例,圆角 20,Download pill。
|
||||
///
|
||||
/// [MyTaskItem.resultUrl] 为生成结果地址(逻辑字段 `imgUrl`,线网常为 `boost`),可能是图片或视频。
|
||||
class HistoryGridCard extends StatelessWidget {
|
||||
const HistoryGridCard({
|
||||
super.key,
|
||||
required this.item,
|
||||
this.localCoverPath,
|
||||
this.onTap,
|
||||
this.onDownload,
|
||||
});
|
||||
|
||||
final MyTaskItem item;
|
||||
final String? localCoverPath;
|
||||
final VoidCallback? onTap;
|
||||
final VoidCallback? onDownload;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = item.resultUrl?.trim() ?? '';
|
||||
final created = item.createTime ?? '—';
|
||||
final dateLabel = _formatCardDate(item.createTime);
|
||||
final remainder = _remainderLabel(item.createTime);
|
||||
|
||||
return LayoutBuilder(
|
||||
@ -33,9 +40,12 @@ class HistoryGridCard extends StatelessWidget {
|
||||
return SizedBox(
|
||||
width: w,
|
||||
height: h,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
@ -43,44 +53,81 @@ class HistoryGridCard extends StatelessWidget {
|
||||
height: h,
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: url.isNotEmpty
|
||||
? CachedNetworkImage(imageUrl: url, fit: BoxFit.cover)
|
||||
: localCoverPath != null
|
||||
? Image.file(File(localCoverPath!), fit: BoxFit.cover)
|
||||
: Container(color: PencilTheme.cardThumbBg),
|
||||
child: _HistoryThumb(
|
||||
networkUrl: url.isNotEmpty ? url : null,
|
||||
localPath: localCoverPath,
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
right: 8,
|
||||
right: 10,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
created,
|
||||
dateLabel,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 9,
|
||||
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(
|
||||
remainder,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 9,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: PencilTheme.underlineGold,
|
||||
shadows: const [
|
||||
Shadow(
|
||||
blurRadius: 4,
|
||||
color: Color(0x40000000),
|
||||
offset: Offset(0, 1),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
right: 8,
|
||||
bottom: 8,
|
||||
child: Material(
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
@ -114,6 +161,7 @@ class HistoryGridCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -121,17 +169,85 @@ class HistoryGridCard extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
String _remainderLabel(String? createTimeRaw) {
|
||||
if (createTimeRaw == null || createTimeRaw.isEmpty) return '—';
|
||||
DateTime? created;
|
||||
final asInt = int.tryParse(createTimeRaw);
|
||||
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));
|
||||
}
|
||||
|
||||
/// 图片:网络图;视频:仅封面(优先本地上传缩略图),不播放视频。
|
||||
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) {
|
||||
var ms = asInt;
|
||||
if (asInt < 2000000000) ms = asInt * 1000;
|
||||
created = DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
} else {
|
||||
created = DateTime.tryParse(createTimeRaw);
|
||||
return DateTime.fromMillisecondsSinceEpoch(ms);
|
||||
}
|
||||
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 '—';
|
||||
final deadline = created.add(const Duration(hours: 24));
|
||||
final left = deadline.difference(DateTime.now());
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' show max;
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.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 '../../widgets/pencil_chrome.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] 为空表示该分类暂无模板占位。
|
||||
class _FlatHomePage {
|
||||
@ -356,7 +370,7 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
padding: const EdgeInsets.only(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 34,
|
||||
bottom: 16,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
@ -364,42 +378,15 @@ class _HomeScreenState extends State<HomeScreen> {
|
||||
SizedBox(
|
||||
height: 56,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
PencilGlassSquareButton(
|
||||
onTap: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const HistoryScreen(),
|
||||
),
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: UserState.credits,
|
||||
builder: (_, credits, _) {
|
||||
return PencilGlassCreditsPill(
|
||||
amountText: credits.toStringAsFixed(2),
|
||||
);
|
||||
},
|
||||
child: const Icon(Icons.history_rounded,
|
||||
color: Colors.white, size: 22),
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
ValueListenableBuilder<int>(
|
||||
valueListenable: UserState.credits,
|
||||
builder: (_, credits, _) {
|
||||
return PencilGlassCreditsPill(
|
||||
amountText: credits.toStringAsFixed(2),
|
||||
);
|
||||
},
|
||||
),
|
||||
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
|
||||
? null
|
||||
: flat[safe].item;
|
||||
final cost = template?.cost ?? 0;
|
||||
final cost = _homeCostDisplay480p(template);
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
|
||||
@ -14,7 +14,10 @@ import 'delete_account_flow.dart';
|
||||
|
||||
/// `5J8Po` 个人中心。
|
||||
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
|
||||
State<ProfileScreen> createState() => _ProfileScreenState();
|
||||
@ -47,12 +50,23 @@ class _ProfileScreenState extends State<ProfileScreen> {
|
||||
padding: const EdgeInsets.fromLTRB(2, 0, 14, 10),
|
||||
child: SizedBox(
|
||||
height: 56,
|
||||
child: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: PencilRoundCloseButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
child: widget.isRootTab
|
||||
? Center(
|
||||
child: Text(
|
||||
'Profile',
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: PencilTheme.ink,
|
||||
),
|
||||
),
|
||||
)
|
||||
: Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: PencilRoundCloseButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
|
||||
@ -459,9 +459,11 @@ class _ProductCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final rawTitle = item.title;
|
||||
final title = (rawTitle != null && rawTitle.trim().isNotEmpty)
|
||||
? rawTitle
|
||||
: 'Credit';
|
||||
final creditsTopLabel = item.credits != null
|
||||
? 'Credits:${item.credits}'
|
||||
: (rawTitle != null && rawTitle.trim().isNotEmpty)
|
||||
? 'Credits:${rawTitle.trim()}'
|
||||
: 'Credits:—';
|
||||
final actual = item.actualAmount ?? '—';
|
||||
final origin = item.originAmount;
|
||||
final bonus = item.bonus;
|
||||
@ -474,7 +476,7 @@ class _ProductCard extends StatelessWidget {
|
||||
onTap: paying ? null : () => onTap(item, index),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: SizedBox(
|
||||
height: 125,
|
||||
height: 130,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
@ -484,7 +486,7 @@ class _ProductCard extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
creditsTopLabel,
|
||||
style: GoogleFonts.inter(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
|
||||
@ -1,13 +1,55 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../history/history_screen.dart';
|
||||
import '../home/home_screen.dart';
|
||||
import '../profile/profile_screen.dart';
|
||||
|
||||
/// 根壳:设计稿首页无底部 Tab,子页由首页按钮/路由 [Navigator.push] 进入。
|
||||
class MainScreen extends StatelessWidget {
|
||||
/// Root shell: bottom tabs **Home**, **History**, **Profile** (English labels).
|
||||
class MainScreen extends StatefulWidget {
|
||||
const MainScreen({super.key});
|
||||
|
||||
@override
|
||||
State<MainScreen> createState() => _MainScreenState();
|
||||
}
|
||||
|
||||
class _MainScreenState extends State<MainScreen> {
|
||||
int _index = 0;
|
||||
|
||||
@override
|
||||
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 file_selector_macos
|
||||
import gal
|
||||
import in_app_purchase_storekit
|
||||
import package_info_plus
|
||||
import shared_preferences_foundation
|
||||
@ -17,6 +18,7 @@ import webview_flutter_wkwebview
|
||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||
DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
|
||||
FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
|
||||
GalPlugin.register(with: registry.registrar(forPlugin: "GalPlugin"))
|
||||
InAppPurchasePlugin.register(with: registry.registrar(forPlugin: "InAppPurchasePlugin"))
|
||||
FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
|
||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||
|
||||
@ -295,6 +295,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
gal:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: gal
|
||||
sha256: "969598f986789127fd407a750413249e1352116d4c2be66e81837ffeeaafdfee"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@ -28,6 +28,7 @@ dependencies:
|
||||
google_fonts: ^6.2.1
|
||||
package_info_plus: ^8.1.2
|
||||
webview_flutter: ^4.13.1
|
||||
gal: ^2.3.2
|
||||
|
||||
dev_dependencies:
|
||||
flutter_test:
|
||||
|
||||
@ -7,8 +7,11 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <gal/gal_plugin_c_api.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
GalPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("GalPluginCApi"));
|
||||
}
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
gal
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user