优化:细节调整

This commit is contained in:
ivan 2026-04-10 15:36:08 +08:00
parent 22f712c5a8
commit 8cc9fe7ce5
22 changed files with 1221 additions and 265 deletions

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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] 720p480p
/// [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));
}

View File

@ -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';
/// WBRp4Credit Record
/// WBRp4Credit 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,
),
),
],
),
),
],
),
),
);
}
}

View File

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

View File

@ -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 20Download 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());

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,7 @@
list(APPEND FLUTTER_PLUGIN_LIST
file_selector_windows
gal
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST