diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1456c3b..863c70f 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,5 +1,8 @@ + + android:icon="@mipmap/ic_launcher" + android:requestLegacyExternalStorage="true"> FunyMee needs camera access to take photos for generation. NSPhotoLibraryUsageDescription FunyMee needs photo library access to choose images for generation. + NSPhotoLibraryAddUsageDescription + FunyMee saves your generated image or video to your photo library when you tap Download. diff --git a/lib/core/auth/auth_service.dart b/lib/core/auth/auth_service.dart index a23e8ef..9ce3658 100644 --- a/lib/core/auth/auth_service.dart +++ b/lib/core/auth/auth_service.dart @@ -87,4 +87,37 @@ class AuthService { /// 登录流程是否已结束(含 fast_login + common_info 链路);用于遮罩,勿用 [loginComplete] 的 Future(首帧可能为 null)。 static ValueNotifier 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); + } } diff --git a/lib/design/pencil_theme.dart b/lib/design/pencil_theme.dart index 937e8fd..fd0a01d 100644 --- a/lib/design/pencil_theme.dart +++ b/lib/design/pencil_theme.dart @@ -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; } diff --git a/lib/features/generate/generate_progress_screen.dart b/lib/features/generate/generate_progress_screen.dart index 3abc4d8..01efe5a 100644 --- a/lib/features/generate/generate_progress_screen.dart +++ b/lib/features/generate/generate_progress_screen.dart @@ -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 { - 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 _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 { _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( - 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( + 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(); } diff --git a/lib/features/generate/generate_result_screen.dart b/lib/features/generate/generate_result_screen.dart index 076ed65..085f361 100644 --- a/lib/features/generate/generate_result_screen.dart +++ b/lib/features/generate/generate_result_screen.dart @@ -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 createState() => _GenerateResultScreenState(); +} + +class _GenerateResultScreenState extends State { + 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 _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 _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( + 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( + 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( - 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'; +} diff --git a/lib/features/generate/generate_screen.dart b/lib/features/generate/generate_screen.dart index 497d40c..a6fa428 100644 --- a/lib/features/generate/generate_screen.dart +++ b/lib/features/generate/generate_screen.dart @@ -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 { 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 { /// 双图:仅当扩展配置 `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 _pickSlot(int slot) async { if (!mounted) return; final source = await _showPickImageSourceSheet(context); @@ -174,8 +210,10 @@ class _GenerateScreenState extends State { 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 { 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 { } } + /// 与当前 [_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 _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 _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 { 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 { 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 { ); } - 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 { ); } } + +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)); +} diff --git a/lib/features/history/credit_record_tab.dart b/lib/features/history/credit_record_tab.dart index 6475ef9..98a192a 100644 --- a/lib/features/history/credit_record_tab.dart +++ b/lib/features/history/credit_record_tab.dart @@ -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 { bool _loading = true; String? _error; List _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 _load() async { @@ -44,18 +61,20 @@ class _CreditRecordTabState extends State { }); } - 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 { } 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, + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/history/history_screen.dart b/lib/features/history/history_screen.dart index 8aab737..2d12d82 100644 --- a/lib/features/history/history_screen.dart +++ b/lib/features/history/history_screen.dart @@ -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 createState() => _HistoryScreenState(); @@ -18,14 +32,47 @@ class HistoryScreen extends StatefulWidget { class _HistoryScreenState extends State { int _tab = 0; - bool _loading = true; + bool _loading = false; String? _error; List _items = []; Map _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 { 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 { } 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 { item: t, localCoverPath: id.isEmpty ? null : _localCovers[id], + onTap: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (_) => GenerateResultScreen( + taskId: id, + resultUrl: t.resultUrl?.trim() ?? '', + ), + ), + ); + }, onDownload: () {}, ); }, diff --git a/lib/features/history/widgets/history_grid_card.dart b/lib/features/history/widgets/history_grid_card.dart index 4305060..5a400a2 100644 --- a/lib/features/history/widgets/history_grid_card.dart +++ b/lib/features/history/widgets/history_grid_card.dart @@ -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()); diff --git a/lib/features/home/home_screen.dart b/lib/features/home/home_screen.dart index b46683f..2dd7db0 100644 --- a/lib/features/home/home_screen.dart +++ b/lib/features/home/home_screen.dart @@ -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 { 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 { SizedBox( height: 56, child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + mainAxisAlignment: MainAxisAlignment.end, children: [ - PencilGlassSquareButton( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const HistoryScreen(), - ), + ValueListenableBuilder( + 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( - valueListenable: UserState.credits, - builder: (_, credits, _) { - return PencilGlassCreditsPill( - amountText: credits.toStringAsFixed(2), - ); - }, - ), - const SizedBox(width: 10), - PencilGlassSquareButton( - onTap: () { - Navigator.of(context).push( - MaterialPageRoute( - builder: (_) => const ProfileScreen(), - ), - ); - }, - child: const Icon(Icons.settings_rounded, - color: Colors.white, size: 22), - ), - ], ), ], ), @@ -661,7 +648,7 @@ class _HomeScreenState extends State { final template = flat.isEmpty ? null : flat[safe].item; - final cost = template?.cost ?? 0; + final cost = _homeCostDisplay480p(template); return Column( mainAxisSize: MainAxisSize.min, diff --git a/lib/features/profile/profile_screen.dart b/lib/features/profile/profile_screen.dart index e3f7e05..1a11d29 100644 --- a/lib/features/profile/profile_screen.dart +++ b/lib/features/profile/profile_screen.dart @@ -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 createState() => _ProfileScreenState(); @@ -47,12 +50,23 @@ class _ProfileScreenState extends State { 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( diff --git a/lib/features/purchase/purchase_screen.dart b/lib/features/purchase/purchase_screen.dart index f224139..66e9f4d 100644 --- a/lib/features/purchase/purchase_screen.dart +++ b/lib/features/purchase/purchase_screen.dart @@ -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, diff --git a/lib/features/shell/main_screen.dart b/lib/features/shell/main_screen.dart index 04bff35..24e8579 100644 --- a/lib/features/shell/main_screen.dart +++ b/lib/features/shell/main_screen.dart @@ -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 createState() => _MainScreenState(); +} + +class _MainScreenState extends State { + 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', + ), + ], + ), + ); } } diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index 8845aa7..d4a162a 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -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")) diff --git a/pubspec.lock b/pubspec.lock index 1a632e4..68db174 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -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: diff --git a/pubspec.yaml b/pubspec.yaml index 7299035..8676f96 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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: diff --git a/windows/flutter/generated_plugin_registrant.cc b/windows/flutter/generated_plugin_registrant.cc index 77ab7a0..c541e13 100644 --- a/windows/flutter/generated_plugin_registrant.cc +++ b/windows/flutter/generated_plugin_registrant.cc @@ -7,8 +7,11 @@ #include "generated_plugin_registrant.h" #include +#include void RegisterPlugins(flutter::PluginRegistry* registry) { FileSelectorWindowsRegisterWithRegistrar( registry->GetRegistrarForPlugin("FileSelectorWindows")); + GalPluginCApiRegisterWithRegistrar( + registry->GetRegistrarForPlugin("GalPluginCApi")); } diff --git a/windows/flutter/generated_plugins.cmake b/windows/flutter/generated_plugins.cmake index 277a56f..9478e5f 100644 --- a/windows/flutter/generated_plugins.cmake +++ b/windows/flutter/generated_plugins.cmake @@ -4,6 +4,7 @@ list(APPEND FLUTTER_PLUGIN_LIST file_selector_windows + gal ) list(APPEND FLUTTER_FFI_PLUGIN_LIST