import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../data/providers.dart'; import '../../data/state/app_interaction_state.dart'; import '../../routing/app_routes.dart'; import '../../theme/app_icons.dart'; import '../../theme/yanting_text.dart'; import '../../theme/yanting_tokens.dart'; import '../../widgets/app_buttons.dart'; import '../../widgets/app_card.dart'; import '../../widgets/page_header.dart'; import '../../widgets/states.dart'; class LoginPage extends HookConsumerWidget { 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 timerRef = useRef(null); final auth = ref.watch(authControllerProvider); final theme = ShadTheme.of(context); useEffect(() { return () { timerRef.value?.cancel(); }; }, const []); Future sendCode() async { final phone = phoneController.text.trim(); if (phone.length != 11) { error.value = '请输入正确的手机号'; return; } 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) { error.value = '请先同意用户协议和隐私政策'; return; } verifying.value = true; error.value = null; try { await ref .read(authControllerProvider.notifier) .login(LoginMethod.phone, phone: phone); await ref.read(profileControllerProvider.notifier).refresh(); if (!context.mounted) return; showAppToast(context, '已登录 ${maskPhone(phone)}'); 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 { verifying.value = false; } } 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: [ 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(); }, ), ), ], ), ), ), ), ); } 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: 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), 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, ), ), ), ], ), ), ); } } String maskPhone(String phone) { if (phone.length < 7) return phone; return '${phone.substring(0, 3)}****${phone.substring(phone.length - 4)}'; }