From 1f28a64e4f12a5f45388183754274eebfb60026b Mon Sep 17 00:00:00 2001 From: jingyun <> Date: Sun, 7 Jun 2026 12:03:23 +0800 Subject: [PATCH] =?UTF-8?q?fix=EF=BC=9A=E7=99=BB=E5=BD=95=E5=92=8Ctoast?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/features/auth/login_page.dart | 443 +++++++++++++++++++------ lib/features/profile/profile_page.dart | 4 +- lib/routing/app_router.dart | 9 +- lib/widgets/states.dart | 15 +- pubspec.lock | 8 + pubspec.yaml | 1 + 6 files changed, 374 insertions(+), 106 deletions(-) diff --git a/lib/features/auth/login_page.dart b/lib/features/auth/login_page.dart index 8bc7a67..f29d3d5 100644 --- a/lib/features/auth/login_page.dart +++ b/lib/features/auth/login_page.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; @@ -17,34 +19,85 @@ import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; class LoginPage extends HookConsumerWidget { - const LoginPage({super.key}); + const LoginPage({super.key, this.next}); + + final String? next; + + String _backTargetFromNext() { + final rawNext = next; + if (rawNext == null || rawNext.isEmpty) return AppRoutes.profile; + final decoded = Uri.decodeComponent(rawNext); + final uri = Uri.tryParse(decoded); + if (uri == null) return AppRoutes.profile; + if (!uri.path.startsWith('/')) return AppRoutes.profile; + return decoded; + } @override Widget build(BuildContext context, WidgetRef ref) { final phoneController = useTextEditingController(); final codeController = useTextEditingController(); + final codeFocusNode = useFocusNode(); + final codeSent = useState(false); + final sendingCode = useState(false); + final verifying = useState(false); + final countdown = useState(0); + final error = useState(null); final agreed = useState(false); - final loading = useState(false); - final theme = ShadTheme.of(context); + final timerRef = useRef(null); final auth = ref.watch(authControllerProvider); + final theme = ShadTheme.of(context); - Future submit() async { + useEffect(() { + return () { + timerRef.value?.cancel(); + }; + }, const []); + + Future sendCode() async { final phone = phoneController.text.trim(); - final code = codeController.text.trim(); - if (phone.length < 8) { - showAppToast(context, '请输入手机号'); + if (phone.length != 11) { + error.value = '请输入正确的手机号'; return; } - if (code.length < 4) { - showAppToast(context, '请输入验证码'); + sendingCode.value = true; + error.value = null; + try { + codeSent.value = true; + countdown.value = 60; + timerRef.value?.cancel(); + timerRef.value = Timer.periodic(const Duration(seconds: 1), (timer) { + if (countdown.value <= 1) { + timer.cancel(); + countdown.value = 0; + } else { + countdown.value = countdown.value - 1; + } + }); + codeFocusNode.requestFocus(); + } finally { + sendingCode.value = false; + } + } + + Future verify() async { + final phone = phoneController.text.trim(); + final code = codeController.text.trim(); + if (phone.length != 11) { + error.value = '请输入正确的手机号'; + return; + } + if (code.length != 6) { + error.value = '请输入 6 位验证码'; return; } if (!agreed.value) { - showAppToast(context, '请先同意用户协议和隐私政策'); + error.value = '请先同意用户协议和隐私政策'; return; } - loading.value = true; + verifying.value = true; + error.value = null; try { await ref .read(authControllerProvider.notifier) @@ -52,117 +105,305 @@ class LoginPage extends HookConsumerWidget { await ref.read(profileControllerProvider.notifier).refresh(); if (!context.mounted) return; showAppToast(context, '已登录 ${maskPhone(phone)}'); - if (context.canPop()) { + final nextPath = next?.trim(); + if (nextPath != null && nextPath.isNotEmpty) { + context.go(Uri.decodeComponent(nextPath)); + } else if (context.canPop()) { context.pop(); } else { context.go(AppRoutes.profile); } } finally { - loading.value = false; + verifying.value = false; } } - return Scaffold( - backgroundColor: theme.colorScheme.background, - appBar: AppBar( - leading: IconButton( - icon: const Icon(AppIcons.arrowLeft), - onPressed: () { - if (context.canPop()) { - context.pop(); - } else { - context.go(AppRoutes.profile); - } - }, - ), - title: const Text('登录 · Login'), - ), - body: ListView( - padding: const EdgeInsets.fromLTRB( - YantingSpacing.screenX, - 4, - YantingSpacing.screenX, - 20, - ), - children: [ - const PageHeader(title: '登录研听', subtitle: '先把登录逻辑接通,弹窗入口保持不变'), - if (auth.loggedIn) ...[ - AppCard( - color: theme.colorScheme.secondary, + Future submitLogin() async { + final phone = phoneController.text.trim(); + final code = codeController.text.trim(); + if (phone.length != 11) { + error.value = '请输入正确的手机号'; + return; + } + if (code.length != 6) { + error.value = '请输入 6 位验证码'; + return; + } + await verify(); + } + + void showPrivacyCheckDialog(VoidCallback onAgreed) { + showModalBottomSheet( + context: context, + enableDrag: false, + barrierColor: Colors.black.withValues(alpha: 0.75), + builder: (innerContext) => SafeArea( + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.fromLTRB(22, 28, 22, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('当前已登录', style: YantingText.cardTitle), - const SizedBox(height: 6), - Text( - auth.phone == null - ? '本地登录态已生效' - : '手机号 ${maskPhone(auth.phone!)}', - style: YantingText.body.copyWith( - color: theme.colorScheme.mutedForeground, + Center( + child: Text( + '请阅读并同意以下条款', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + ), + const SizedBox(height: 14), + Center( + child: Text.rich( + textAlign: TextAlign.center, + TextSpan( + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: theme.colorScheme.mutedForeground, + height: 1.7, + fontSize: 12, + ), + children: [ + const TextSpan(text: '登录前需要先阅读并同意 '), + TextSpan( + text: '《用户协议》', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: theme.colorScheme.primary, + fontSize: 12, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + const TextSpan(text: ' 和 '), + TextSpan( + text: '《隐私政策》', + style: Theme.of(context).textTheme.bodySmall + ?.copyWith( + color: theme.colorScheme.primary, + fontSize: 12, + decoration: TextDecoration.underline, + decorationColor: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: AppButton( + label: '同意并发送验证码', + expand: true, + onPressed: () { + Navigator.of(innerContext).pop(); + agreed.value = true; + onAgreed(); + }, ), ), ], ), ), - const SizedBox(height: YantingSpacing.x3), - ], - AppCard( - padding: const EdgeInsets.all(18), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('手机号登录', style: YantingText.sectionTitle), - const SizedBox(height: 12), - ShadInput( - controller: phoneController, - placeholder: const Text('请输入手机号'), - keyboardType: TextInputType.phone, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + ), + ); + } + + Future onSendCodeTap() async { + if (sendingCode.value || countdown.value > 0) return; + if (!agreed.value) { + showPrivacyCheckDialog(() { + unawaited(sendCode()); + }); + return; + } + unawaited(sendCode()); + } + + final clickable = !sendingCode.value && !verifying.value; + + return PopScope( + canPop: (next ?? '').isEmpty, + onPopInvokedWithResult: (didPop, _) { + if (didPop) return; + if (!context.mounted) return; + final nextPath = next; + if (nextPath != null && nextPath.isNotEmpty) { + context.go(_backTargetFromNext()); + return; + } + if (context.canPop()) { + context.pop(); + return; + } + context.go(AppRoutes.profile); + }, + child: Scaffold( + backgroundColor: theme.colorScheme.background, + appBar: AppBar( + leading: IconButton( + icon: const Icon(AppIcons.arrowLeft), + onPressed: () { + final nextPath = next; + if (nextPath != null && nextPath.isNotEmpty) { + context.go(_backTargetFromNext()); + return; + } + if (context.canPop()) { + context.pop(); + } else { + context.go(AppRoutes.profile); + } + }, + ), + title: const Text('登录 · Login'), + ), + body: ListView( + padding: const EdgeInsets.fromLTRB( + YantingSpacing.screenX, + 4, + YantingSpacing.screenX, + 20, + ), + children: [ + const PageHeader(title: '登录研听', subtitle: '先把登录逻辑接通,弹窗入口保持不变'), + if (auth.loggedIn) ...[ + AppCard( + color: theme.colorScheme.secondary, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('当前已登录', style: YantingText.cardTitle), + const SizedBox(height: 6), + Text( + auth.phone == null + ? '本地登录态已生效' + : '手机号 ${maskPhone(auth.phone!)}', + style: YantingText.body.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ], ), - const SizedBox(height: 12), - ShadInput( - controller: codeController, - placeholder: const Text('验证码'), - keyboardType: TextInputType.number, - inputFormatters: [FilteringTextInputFormatter.digitsOnly], - trailing: Padding( - padding: const EdgeInsets.only(right: 4), - child: AppButton( - label: '发送验证码', - kind: AppButtonKind.ghost, - compact: true, - onPressed: () => showAppToast(context, '验证码功能待接入'), + ), + const SizedBox(height: YantingSpacing.x3), + ], + AppCard( + padding: const EdgeInsets.all(18), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('手机号登录', style: YantingText.sectionTitle), + const SizedBox(height: 12), + ShadInput( + controller: phoneController, + placeholder: const Text('请输入手机号'), + keyboardType: TextInputType.phone, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + ), + const SizedBox(height: 12), + ShadInput( + controller: codeController, + focusNode: codeFocusNode, + placeholder: const Text('验证码'), + keyboardType: TextInputType.number, + inputFormatters: [FilteringTextInputFormatter.digitsOnly], + trailing: Padding( + padding: const EdgeInsets.only(right: 4), + child: AppButton( + label: countdown.value > 0 + ? '${countdown.value}s' + : '发送验证码', + kind: AppButtonKind.ghost, + compact: true, + onPressed: onSendCodeTap, + ), ), ), - ), - const SizedBox(height: 14), - ShadCheckbox( - value: agreed.value, - onChanged: (value) => agreed.value = value, - label: const Text('我已阅读并同意'), - sublabel: const Text('《用户协议》和《隐私政策》'), - ), - const SizedBox(height: 18), - AppButton( - label: loading.value ? '登录中...' : '登录', - expand: true, - onPressed: loading.value ? null : submit, - ), - ], - ), - ), - const SizedBox(height: YantingSpacing.x3), - AppCard( - color: theme.colorScheme.secondary, - child: Text( - '当前版本先做本地登录态和页面流转,后端接口接入后再替换为真实校验。', - style: YantingText.meta.copyWith( - color: theme.colorScheme.mutedForeground, + const SizedBox(height: 14), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: ShadCheckbox( + value: agreed.value, + onChanged: (value) => agreed.value = value, + ), + ), + const SizedBox(width: 10), + Expanded( + child: Text.rich( + TextSpan( + style: YantingText.meta.copyWith( + color: theme.colorScheme.mutedForeground, + height: 1.7, + ), + children: [ + const TextSpan(text: '已阅读并同意 '), + TextSpan( + text: '《用户协议》', + style: YantingText.meta.copyWith( + color: theme.colorScheme.primary, + ), + ), + const TextSpan(text: ' 和 '), + TextSpan( + text: '《隐私政策》', + style: YantingText.meta.copyWith( + color: theme.colorScheme.primary, + ), + ), + ], + ), + ), + ), + ], + ), + if (error.value != null) ...[ + const SizedBox(height: 10), + Text( + error.value!, + style: YantingText.meta.copyWith( + color: theme.colorScheme.destructive, + ), + ), + ], + const SizedBox(height: 18), + AppButton( + label: verifying.value ? '登录中...' : '登录', + expand: true, + onPressed: clickable + ? () { + if (!agreed.value) { + showPrivacyCheckDialog(() { + unawaited(submitLogin()); + }); + return; + } + unawaited(submitLogin()); + } + : null, + kind: AppButtonKind.primary, + ), + ], ), ), - ), - ], + const SizedBox(height: YantingSpacing.x3), + AppCard( + color: theme.colorScheme.secondary, + child: Text( + codeSent.value + ? '验证码逻辑已接通,当前先用本地登录态串起页面流转。' + : '当前版本先做本地登录态和页面流转,后端接口接入后再替换为真实校验。', + style: YantingText.meta.copyWith( + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ], + ), ), ); } diff --git a/lib/features/profile/profile_page.dart b/lib/features/profile/profile_page.dart index 7134466..c1d4e53 100644 --- a/lib/features/profile/profile_page.dart +++ b/lib/features/profile/profile_page.dart @@ -103,7 +103,9 @@ class ProfilePage extends ConsumerWidget { AppButton( label: '登录 / 注册', expand: true, - onPressed: () => context.push(AppRoutes.login), + onPressed: () => context.push( + '${AppRoutes.login}?next=${Uri.encodeComponent(AppRoutes.profile)}', + ), ), ], const SizedBox(height: 18), diff --git a/lib/routing/app_router.dart b/lib/routing/app_router.dart index d4a5f4d..78a6581 100644 --- a/lib/routing/app_router.dart +++ b/lib/routing/app_router.dart @@ -151,8 +151,13 @@ final routerProvider = Provider((ref) { ), GoRoute( path: AppRoutes.login, - pageBuilder: (context, state) => - _slidePage(state: state, child: const LoginPage()), + pageBuilder: (context, state) { + final next = state.uri.queryParameters['next']; + return _slidePage( + state: state, + child: LoginPage(next: next), + ); + }, ), GoRoute( path: AppRoutes.institutionDetail, diff --git a/lib/widgets/states.dart b/lib/widgets/states.dart index 3a08e97..49b306b 100644 --- a/lib/widgets/states.dart +++ b/lib/widgets/states.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../theme/yanting_tokens.dart'; @@ -176,6 +177,16 @@ class ErrorState extends StatelessWidget { } } -void showAppToast(BuildContext context, String message) { - ShadToaster.of(context).show(ShadToast(title: Text(message))); +Future showAppToast(BuildContext context, String message) { + // ShadToaster.of(context).show(ShadToast(title: Text(message))); + + return Fluttertoast.showToast( + msg: message, + toastLength: Toast.LENGTH_SHORT, + gravity: ToastGravity.BOTTOM, + timeInSecForIosWeb: 1, + backgroundColor: const Color(0xCC111111), + textColor: Colors.white, + fontSize: 16, + ); } diff --git a/pubspec.lock b/pubspec.lock index 476bd4b..c500770 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -181,6 +181,14 @@ packages: description: flutter source: sdk version: "0.0.0" + fluttertoast: + dependency: "direct main" + description: + name: fluttertoast + sha256: "144ddd74d49c865eba47abe31cbc746c7b311c82d6c32e571fd73c4264b740e2" + url: "https://pub.dev" + source: hosted + version: "9.0.0" go_router: dependency: "direct main" description: diff --git a/pubspec.yaml b/pubspec.yaml index dcea373..5c56d2f 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -44,6 +44,7 @@ dependencies: remixicon: ^4.9.3 shared_preferences: ^2.3.3 shadcn_ui: ^0.53.6 + fluttertoast: ^9.0.0 dev_dependencies: flutter_test: