From 5746d773de77fe96bc7207e7c12ce58b0fa842dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E6=96=8C?= <259278618@qq.com> Date: Tue, 4 Aug 2026 17:04:18 +0800 Subject: [PATCH] 1 --- app/lib/core/config/app_config.dart | 8 +++ app/lib/features/auth/login_page.dart | 30 ++++++--- .../features/cps/cps_product_list_page.dart | 9 ++- app/lib/features/member/pay_page.dart | 4 +- app/lib/features/mine/mine_page.dart | 14 ++-- app/lib/features/outfit/outfit_page.dart | 12 ++-- .../profile/avatar_build_flow_page.dart | 11 ++-- .../features/profile/avatar_viewer_page.dart | 4 +- app/lib/features/wardrobe/wardrobe_page.dart | 4 +- .../wardrobe/wardrobe_upload_page.dart | 11 ++-- app/lib/main.dart | 2 + app/lib/shared/app_toast.dart | 60 +++++++++++++++++ app/pubspec.lock | 8 --- app/pubspec.yaml | 1 - app/test/shared/app_toast_test.dart | 65 +++++++++++++++++++ 15 files changed, 187 insertions(+), 56 deletions(-) create mode 100644 app/lib/shared/app_toast.dart create mode 100644 app/test/shared/app_toast_test.dart diff --git a/app/lib/core/config/app_config.dart b/app/lib/core/config/app_config.dart index d77b6ff..536aa17 100644 --- a/app/lib/core/config/app_config.dart +++ b/app/lib/core/config/app_config.dart @@ -11,4 +11,12 @@ class AppConfig { /// 穿山甲 App ID(空 = 广告未开通,广告位不渲染) static const String pangleAppId = ''; + + /// 测试账号:联调/测试用真实用户数据,默认 wenwu901/123456, + /// 可用 --dart-define=TEST_ACCOUNT=xxx --dart-define=TEST_PASSWORD=xxx 覆盖(build_web.sh 透传环境变量); + /// 置空则登录页不显示测试登录入口 + static const String testAccount = + String.fromEnvironment('TEST_ACCOUNT', defaultValue: 'wenwu901'); + static const String testPassword = + String.fromEnvironment('TEST_PASSWORD', defaultValue: '123456'); } diff --git a/app/lib/features/auth/login_page.dart b/app/lib/features/auth/login_page.dart index 71383de..c99a25e 100644 --- a/app/lib/features/auth/login_page.dart +++ b/app/lib/features/auth/login_page.dart @@ -1,9 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import '../../core/auth/auth_provider.dart'; +import '../../core/config/app_config.dart'; +import '../../shared/app_toast.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -23,18 +24,17 @@ class _LoginPageState extends ConsumerState { super.dispose(); } - Future _submit() async { - final account = _accountCtrl.text.trim(); - final password = _passwordCtrl.text; + Future _submit([String? acc, String? pwd]) async { + final account = (acc ?? _accountCtrl.text).trim(); + final password = pwd ?? _passwordCtrl.text; if (account.isEmpty || password.isEmpty) { - Fluttertoast.showToast(msg: '请输入账号和密码'); + showToast('请输入账号和密码'); return; } await ref.read(authProvider.notifier).login(account, password); final state = ref.read(authProvider); if (state.hasError) { - Fluttertoast.showToast( - msg: state.error.toString().replaceFirst('Exception: ', '')); + showToast(state.error.toString().replaceFirst('Exception: ', '')); return; } if (state.value?.authenticated == true && mounted) { @@ -83,13 +83,12 @@ class _LoginPageState extends ConsumerState { 'password': password.text, 'name': name.text.trim(), }); - Fluttertoast.showToast(msg: '注册成功,正在登录...'); + showToast('注册成功,正在登录...'); _accountCtrl.text = account.text.trim(); _passwordCtrl.text = password.text; await _submit(); } catch (e) { - Fluttertoast.showToast( - msg: '注册失败:${e.toString().replaceFirst('Exception: ', '')}'); + showToast('注册失败:${e.toString().replaceFirst('Exception: ', '')}'); } } @@ -146,6 +145,17 @@ class _LoginPageState extends ConsumerState { child: CircularProgressIndicator(strokeWidth: 2)) : const Text('登录'), ), + if (AppConfig.testAccount.isNotEmpty) ...[ + const SizedBox(height: 8), + OutlinedButton.icon( + onPressed: loading + ? null + : () => _submit( + AppConfig.testAccount, AppConfig.testPassword), + icon: const Icon(Icons.science_outlined, size: 18), + label: Text('测试账号一键登录(${AppConfig.testAccount})'), + ), + ], const SizedBox(height: 8), TextButton( onPressed: loading ? null : _showRegisterDialog, diff --git a/app/lib/features/cps/cps_product_list_page.dart b/app/lib/features/cps/cps_product_list_page.dart index e1ca0ac..9291f12 100644 --- a/app/lib/features/cps/cps_product_list_page.dart +++ b/app/lib/features/cps/cps_product_list_page.dart @@ -1,11 +1,11 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:url_launcher/url_launcher.dart'; import '../../core/config/app_config.dart'; import '../../features/member/member_provider.dart'; +import '../../shared/app_toast.dart'; import '../../shared/widgets/empty_view.dart'; import '../../shared/widgets/error_view.dart'; import '../../shared/widgets/loading_view.dart'; @@ -50,7 +50,7 @@ class _CpsProductListPageState extends ConsumerState { final link = await ref.read(cpsLinkProvider).link(ref, productId: product.id, scene: scene, planId: planId); if (link.isEmpty) { - Fluttertoast.showToast(msg: '暂无可跳转链接'); + showToast('暂无可跳转链接'); return; } final uri = Uri.parse(link); @@ -63,15 +63,14 @@ class _CpsProductListPageState extends ConsumerState { } } catch (e) { if (!mounted) return; - Fluttertoast.showToast( - msg: '跳转失败:${e.toString().replaceFirst('Exception: ', '')}'); + showToast('跳转失败:${e.toString().replaceFirst('Exception: ', '')}'); } } Future _copy(String text) async { final data = ClipboardData(text: text); await Clipboard.setData(data); - if (mounted) Fluttertoast.showToast(msg: '已复制,打开淘宝即可购买'); + if (mounted) showToast('已复制,打开淘宝即可购买'); } @override diff --git a/app/lib/features/member/pay_page.dart b/app/lib/features/member/pay_page.dart index 5df5061..c4eefa4 100644 --- a/app/lib/features/member/pay_page.dart +++ b/app/lib/features/member/pay_page.dart @@ -2,10 +2,10 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../shared/app_toast.dart'; import 'member_provider.dart'; class PayArgs { @@ -68,7 +68,7 @@ class _PayPageState extends ConsumerState { _timer?.cancel(); if (!mounted) return; setState(() => _phase = PayPhase.paid); - Fluttertoast.showToast(msg: '会员开通成功'); + showToast('会员开通成功'); return; } if (_elapsed >= 60) { diff --git a/app/lib/features/mine/mine_page.dart b/app/lib/features/mine/mine_page.dart index cfd3697..dc4d867 100644 --- a/app/lib/features/mine/mine_page.dart +++ b/app/lib/features/mine/mine_page.dart @@ -1,13 +1,13 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import '../../core/ads/ads_service.dart'; import '../../core/auth/auth_provider.dart'; import '../../core/config/app_config.dart'; import '../../features/cps/cps_provider.dart'; +import '../../shared/app_toast.dart'; import '../../shared/widgets/loading_view.dart'; import '../member/member_provider.dart'; import '../member/pay_page.dart'; @@ -41,8 +41,7 @@ class _MinePageState extends ConsumerState { ref.read(memberProvider.notifier).refresh(); } catch (e) { if (!mounted) return; - Fluttertoast.showToast( - msg: e.toString().replaceFirst('Exception: ', '')); + showToast(e.toString().replaceFirst('Exception: ', '')); } } @@ -50,24 +49,23 @@ class _MinePageState extends ConsumerState { if (_rewarding) return; final ads = ref.read(adsServiceProvider); if (!ads.enabled) { - Fluttertoast.showToast(msg: '广告功能暂未开通'); + showToast('广告功能暂未开通'); return; } setState(() => _rewarding = true); try { final watched = await ads.showRewarded(); if (!watched) { - Fluttertoast.showToast(msg: '未完整观看,无法领取'); + showToast('未完整观看,无法领取'); return; } final remaining = await ref.read(memberProvider.notifier).claimReward(adType); if (!mounted) return; - Fluttertoast.showToast(msg: '$successMsg(今日剩余 $remaining 次)'); + showToast('$successMsg(今日剩余 $remaining 次)'); } catch (e) { if (!mounted) return; - Fluttertoast.showToast( - msg: e.toString().replaceFirst('Exception: ', '')); + showToast(e.toString().replaceFirst('Exception: ', '')); } finally { if (mounted) setState(() => _rewarding = false); } diff --git a/app/lib/features/outfit/outfit_page.dart b/app/lib/features/outfit/outfit_page.dart index 11544d1..58fdb59 100644 --- a/app/lib/features/outfit/outfit_page.dart +++ b/app/lib/features/outfit/outfit_page.dart @@ -1,8 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; +import '../../shared/app_toast.dart'; import '../../shared/widgets/empty_view.dart'; import '../../shared/widgets/error_view.dart'; import '../../shared/widgets/loading_view.dart'; @@ -68,19 +68,19 @@ class _OutfitPageState extends ConsumerState { final end = _endDate; final location = _locationCtrl.text.trim(); if (start == null) { - Fluttertoast.showToast(msg: '请选择开始日期'); + showToast('请选择开始日期'); return; } if (end == null) { - Fluttertoast.showToast(msg: '请选择结束日期'); + showToast('请选择结束日期'); return; } if (location.isEmpty) { - Fluttertoast.showToast(msg: '请输入地点'); + showToast('请输入地点'); return; } if (end.difference(start).inDays < 1) { - Fluttertoast.showToast(msg: '日期范围至少 1 天'); + showToast('日期范围至少 1 天'); return; } setState(() => _submitting = true); @@ -93,7 +93,7 @@ class _OutfitPageState extends ConsumerState { if (!mounted) return; setState(() => _submitting = false); if (ok) { - Fluttertoast.showToast(msg: '生成完成'); + showToast('生成完成'); ref.read(generateProvider.notifier).reset(); context.push('/plan-viewer'); } diff --git a/app/lib/features/profile/avatar_build_flow_page.dart b/app/lib/features/profile/avatar_build_flow_page.dart index 3560866..2e2a29f 100644 --- a/app/lib/features/profile/avatar_build_flow_page.dart +++ b/app/lib/features/profile/avatar_build_flow_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import '../../shared/app_toast.dart'; import '../../shared/widgets/loading_view.dart'; import 'avatar_provider.dart'; import 'body_provider.dart'; @@ -88,11 +88,10 @@ class _AvatarBuildFlowPageState extends ConsumerState { try { await ref.read(photoProvider.notifier).upload(type, file.path); if (!mounted) return; - Fluttertoast.showToast(msg: '${PhotoType.labels[type]}上传成功'); + showToast('${PhotoType.labels[type]}上传成功'); } catch (e) { if (!mounted) return; - Fluttertoast.showToast( - msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}'); + showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}'); } finally { if (mounted) setState(() => _uploadingType = null); } @@ -111,12 +110,12 @@ class _AvatarBuildFlowPageState extends ConsumerState { if (!mounted) return; final a = ref.read(avatarProvider).value; if (a?.built == true) { - Fluttertoast.showToast(msg: '3D 化身已生成'); + showToast('3D 化身已生成'); context.go('/avatar-viewer'); } else if (a?.buildStatus == 'failed') { setState(() => _buildError = a!.error); } else { - Fluttertoast.showToast(msg: '构建超时,请稍后重试'); + showToast('构建超时,请稍后重试'); } } catch (e) { if (mounted) { diff --git a/app/lib/features/profile/avatar_viewer_page.dart b/app/lib/features/profile/avatar_viewer_page.dart index 98882fe..b408709 100644 --- a/app/lib/features/profile/avatar_viewer_page.dart +++ b/app/lib/features/profile/avatar_viewer_page.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; +import '../../shared/app_toast.dart'; import '../../shared/widgets/avatar_viewer.dart'; import '../../shared/widgets/loading_view.dart'; import 'avatar_provider.dart'; @@ -54,7 +54,7 @@ class AvatarViewerPage extends ConsumerWidget { .read(avatarProvider.notifier) .buildAvatar(); if (context.mounted) { - Fluttertoast.showToast(msg: '化身构建完成'); + showToast('化身构建完成'); } }, icon: const Icon(Icons.refresh), diff --git a/app/lib/features/wardrobe/wardrobe_page.dart b/app/lib/features/wardrobe/wardrobe_page.dart index e1b808a..8996c44 100644 --- a/app/lib/features/wardrobe/wardrobe_page.dart +++ b/app/lib/features/wardrobe/wardrobe_page.dart @@ -1,9 +1,9 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import '../../core/config/app_config.dart'; +import '../../shared/app_toast.dart'; import '../../features/cps/cps_provider.dart'; import '../../shared/widgets/empty_view.dart'; import '../../shared/widgets/error_view.dart'; @@ -55,7 +55,7 @@ class _WardrobePageState extends ConsumerState { final rec = await ref.read(cpsUpgradeProvider(item.id).future); if (!mounted) return; if (rec.isEmpty) { - Fluttertoast.showToast(msg: '暂无升级款'); + showToast('暂无升级款'); return; } final first = rec.first; diff --git a/app/lib/features/wardrobe/wardrobe_upload_page.dart b/app/lib/features/wardrobe/wardrobe_upload_page.dart index 842479b..1e43e7c 100644 --- a/app/lib/features/wardrobe/wardrobe_upload_page.dart +++ b/app/lib/features/wardrobe/wardrobe_upload_page.dart @@ -2,10 +2,10 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; import 'package:image_picker/image_picker.dart'; +import '../../shared/app_toast.dart'; import 'wardrobe_provider.dart'; /// 服装上传:选图 + 分类/季节/风格/颜色信息 @@ -42,11 +42,11 @@ class _WardrobeUploadPageState extends ConsumerState { Future _submit() async { if (_imagePath == null) { - Fluttertoast.showToast(msg: '请先选择服装照片'); + showToast('请先选择服装照片'); return; } if (_category == null) { - Fluttertoast.showToast(msg: '请选择分类'); + showToast('请选择分类'); return; } setState(() => _uploading = true); @@ -59,12 +59,11 @@ class _WardrobeUploadPageState extends ConsumerState { colorInfo: _colorCtrl.text.trim(), ); if (!mounted) return; - Fluttertoast.showToast(msg: '上传成功'); + showToast('上传成功'); context.go('/wardrobe'); } catch (e) { if (!mounted) return; - Fluttertoast.showToast( - msg: '上传失败:${e.toString().replaceFirst('Exception: ', '')}'); + showToast('上传失败:${e.toString().replaceFirst('Exception: ', '')}'); } finally { if (mounted) setState(() => _uploading = false); } diff --git a/app/lib/main.dart b/app/lib/main.dart index da9440d..609b1f5 100644 --- a/app/lib/main.dart +++ b/app/lib/main.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'core/auth/auth_provider.dart'; +import 'shared/app_toast.dart'; import 'features/auth/login_page.dart'; import 'features/cps/cps_product_list_page.dart'; import 'features/cps/cps_provider.dart'; @@ -58,6 +59,7 @@ class SloganApp extends ConsumerWidget { /// 路由表:启动无 token 进登录页;有 token 进主框架 final routerProvider = Provider((ref) { return GoRouter( + navigatorKey: appNavigatorKey, initialLocation: '/login', routes: [ GoRoute( diff --git a/app/lib/shared/app_toast.dart b/app/lib/shared/app_toast.dart new file mode 100644 index 0000000..c44aed7 --- /dev/null +++ b/app/lib/shared/app_toast.dart @@ -0,0 +1,60 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +/// 全局 Navigator key,供不依赖页面 context 的 toast 等场景使用 +final GlobalKey appNavigatorKey = GlobalKey(); + +OverlayEntry? _entry; +Timer? _timer; + +/// 屏幕正中央的 toast,展示时长按文字长度分档:≤8 字 2 秒,≤20 字 3 秒,更长 4 秒 +void showToast(String msg) { + _timer?.cancel(); + _entry?.remove(); + _entry = null; + + final overlay = appNavigatorKey.currentState?.overlay; + if (overlay == null) return; + + final entry = OverlayEntry( + builder: (_) => Positioned.fill( + child: IgnorePointer( + child: Center( + child: Container( + constraints: const BoxConstraints(maxWidth: 280), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), + decoration: BoxDecoration( + color: Colors.black.withValues(alpha: 0.78), + borderRadius: BorderRadius.circular(8), + ), + child: Text( + msg, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white, + fontSize: 14, + height: 1.4, + ), + ), + ), + ), + ), + ), + ); + overlay.insert(entry); + _entry = entry; + _timer = Timer(_durationFor(msg), () { + if (_entry == entry) { + entry.remove(); + _entry = null; + } + }); +} + +Duration _durationFor(String msg) { + final len = msg.length; + if (len <= 8) return const Duration(seconds: 2); + if (len <= 20) return const Duration(seconds: 3); + return const Duration(seconds: 4); +} diff --git a/app/pubspec.lock b/app/pubspec.lock index ec58cec..e2642ce 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -232,14 +232,6 @@ packages: description: flutter source: sdk version: "0.0.0" - fluttertoast: - dependency: "direct main" - description: - name: fluttertoast - sha256: "7903c9d5339173497bfecbc23bc4212f5a87e0edfac2e1693fb74465ea67da7e" - url: "https://pub.flutter-io.cn" - source: hosted - version: "9.1.0" frontend_server_client: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index e05ebb9..5beb391 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -39,7 +39,6 @@ dependencies: go_router: ^17.3.0 image_picker: ^1.2.3 shared_preferences: ^2.5.5 - fluttertoast: ^9.1.0 url_launcher: ^6.3.0 # v2 3D 渲染接入时引入:three_dart ^0.0.16 + three_dart_jsm ^0.0.10 + flutter_gl ^0.0.20 # (flutter_gl 0.0.21 在 Flutter 3.44 下无法编译:platformViewRegistry 已移除) diff --git a/app/test/shared/app_toast_test.dart b/app/test/shared/app_toast_test.dart new file mode 100644 index 0000000..a10cdbc --- /dev/null +++ b/app/test/shared/app_toast_test.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:slogan_app/shared/app_toast.dart'; + +void main() { + testWidgets('showToast 显示在屏幕正中心', (tester) async { + await tester.pumpWidget(MaterialApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: Center(child: Text('页面'))), + )); + + showToast('上传成功'); + await tester.pump(); + + final text = find.text('上传成功'); + expect(text, findsOneWidget); + final center = tester.getCenter(text); + final screen = tester.getSize(find.byType(MaterialApp)); + expect(center.dx, closeTo(screen.width / 2, 1)); + expect(center.dy, closeTo(screen.height / 2, 1)); + + // 等 toast 消失,避免测试结束时残留 Timer + await tester.pump(const Duration(seconds: 3)); + }); + + testWidgets('短文本 2 秒后消失', (tester) async { + await tester.pumpWidget(MaterialApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: Center(child: Text('页面'))), + )); + + showToast('上传成功'); + await tester.pump(); + expect(find.text('上传成功'), findsOneWidget); + + // 2 秒整之前仍在 + await tester.pump(const Duration(milliseconds: 1900)); + expect(find.text('上传成功'), findsOneWidget); + + // 超过 2 秒后消失 + await tester.pump(const Duration(milliseconds: 200)); + expect(find.text('上传成功'), findsNothing); + }); + + testWidgets('长文本 4 秒后消失', (tester) async { + await tester.pumpWidget(MaterialApp( + navigatorKey: appNavigatorKey, + home: const Scaffold(body: Center(child: Text('页面'))), + )); + + showToast('这是一个超过二十个字符的非常长的提示消息内容展示测试'); + await tester.pump(); + expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), + findsOneWidget); + + await tester.pump(const Duration(seconds: 3)); + expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), + findsOneWidget); + + await tester.pump(const Duration(seconds: 1, milliseconds: 500)); + expect(find.text('这是一个超过二十个字符的非常长的提示消息内容展示测试'), + findsNothing); + }); +}