import 'dart:async'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_remix/flutter_remix.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../../common/config/app_h5_urls.dart'; import '../../../common/network/api_error_message.dart'; import '../../../common/router/app_router.dart'; import '../../../common/theme/spacing.dart'; import '../../../common/widgets/circle_back_button.dart'; import '../application/auth_controller.dart'; class LoginPage extends HookConsumerWidget { const LoginPage({super.key, this.next}); final String? next; @override Widget build(BuildContext context, WidgetRef ref) { final cs = ShadTheme.of(context).colorScheme; final phone = useTextEditingController(); final codeCtrl = useTextEditingController(); final codeFocus = useFocusNode(); final onCodeStep = useState(false); final agreed = useState(false); final sending = useState(false); final verifying = useState(false); final countdown = useState(0); final error = useState(null); final timer = useRef(null); useListenable(phone); useListenable(codeCtrl); useEffect( () => () => timer.value?.cancel(), const [], ); String phoneDigits() => phone.text.replaceAll(RegExp(r'\D'), ''); Future returnAfterLogin() async { final target = next?.trim(); final router = GoRouter.of(context); if (target == null || target.isEmpty) { router.go(AppRoutes.profile); return; } router.go(target); } void startCountdown() { countdown.value = 60; timer.value?.cancel(); timer.value = Timer.periodic(const Duration(seconds: 1), (t) { if (countdown.value <= 1) { t.cancel(); countdown.value = 0; } else { countdown.value--; } }); } Future doSend() async { if (sending.value || countdown.value > 0) return; if (phoneDigits().length != 11) { error.value = '请输入正确的手机号'; return; } sending.value = true; error.value = null; try { await ref.read(authProvider.notifier).sendCode(phoneDigits()); startCountdown(); onCodeStep.value = true; codeFocus.requestFocus(); } catch (e) { error.value = kratosDisplayMessage(e, fallback: '发送失败,请稍后重试'); } finally { sending.value = false; } } Future doVerify() async { if (verifying.value) return; final code = codeCtrl.text.trim(); if (code.length != 6) { error.value = '请输入 6 位验证码'; return; } verifying.value = true; error.value = null; try { await ref.read(authProvider.notifier).verifyCode(phoneDigits(), code); if (!context.mounted) return; await returnAfterLogin(); } catch (e) { error.value = kratosDisplayMessage(e, fallback: '验证失败,请重试'); } finally { verifying.value = false; } } void onBack() { if (onCodeStep.value) { onCodeStep.value = false; error.value = null; } else if (context.canPop()) { context.pop(); } else { context.go(AppRoutes.profile); } } void showPrivacySheet(VoidCallback onAgreed) { showModalBottomSheet( context: context, backgroundColor: cs.card, barrierColor: Colors.black.withValues(alpha: 0.6), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical( top: Radius.circular(Spacing.radiusCard), ), ), builder: (ctx) => SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(22, 28, 22, 24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Text( '请阅读并同意以下条款', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: cs.foreground, ), ), const SizedBox(height: 14), Text.rich( textAlign: TextAlign.center, _policySpan(context, cs, fontSize: 12.5), ), const SizedBox(height: 22), _PrimaryButton( label: '同意并继续', enabled: true, onTap: () { Navigator.of(ctx).pop(); onAgreed(); }, ), ], ), ), ), ); } void onSendTap() { if (sending.value || countdown.value > 0) return; if (phoneDigits().length != 11) { error.value = '请输入正确的手机号'; return; } error.value = null; if (!agreed.value) { showPrivacySheet(() { agreed.value = true; doSend(); }); return; } doSend(); } final phoneReady = phoneDigits().length == 11; final codeReady = codeCtrl.text.trim().length == 6; return Scaffold( backgroundColor: cs.background, appBar: AppBar( backgroundColor: cs.background, surfaceTintColor: Colors.transparent, elevation: 0, scrolledUnderElevation: 0, automaticallyImplyLeading: false, toolbarHeight: 46, titleSpacing: 0, title: CircleBackButton(onTap: onBack), ), body: SafeArea( top: false, child: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(22, 8, 22, 28), child: onCodeStep.value ? _CodeStep( cs: cs, phone: phoneDigits(), codeCtrl: codeCtrl, codeFocus: codeFocus, countdown: countdown.value, verifying: verifying.value, codeReady: codeReady, error: error.value, onVerify: doVerify, onResend: doSend, ) : _PhoneStep( cs: cs, phone: phone, sending: sending.value, countdown: countdown.value, phoneReady: phoneReady, error: error.value, agreed: agreed.value, onToggleAgreed: () => agreed.value = !agreed.value, onSend: onSendTap, ), ), ), ); } } class _PhoneStep extends StatelessWidget { const _PhoneStep({ required this.cs, required this.phone, required this.sending, required this.countdown, required this.phoneReady, required this.agreed, required this.onToggleAgreed, required this.error, required this.onSend, }); final ShadColorScheme cs; final TextEditingController phone; final bool sending; final int countdown; final bool phoneReady; final bool agreed; final VoidCallback onToggleAgreed; final String? error; final VoidCallback onSend; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SheetTitle('手机号登录', cs), const SizedBox(height: 8), _SheetSub('未注册的手机号验证后将自动创建金值账号。', cs), const SizedBox(height: 18), Row( children: [ SizedBox( height: 50, child: Center( child: Text( '+86', style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: cs.foreground, fontFeatures: const [FontFeature.tabularFigures()], ), ), ), ), const SizedBox(width: 10), Expanded( child: _BoxedField(controller: phone, hint: '请输入手机号', cs: cs), ), ], ), if (error != null) ...[ const SizedBox(height: 10), _ErrorText(error!, cs), ], const SizedBox(height: 18), _PrimaryButton( label: countdown > 0 ? '$countdown s 后可重新发送' : '获取验证码', enabled: !sending && countdown == 0, loading: sending, dim: !phoneReady, onTap: onSend, ), const SizedBox(height: 18), _AgreeRow(agreed: agreed, onToggle: onToggleAgreed, cs: cs), ], ); } } class _CodeStep extends StatelessWidget { const _CodeStep({ required this.cs, required this.phone, required this.codeCtrl, required this.codeFocus, required this.countdown, required this.verifying, required this.codeReady, required this.error, required this.onVerify, required this.onResend, }); final ShadColorScheme cs; final String phone; final TextEditingController codeCtrl; final FocusNode codeFocus; final int countdown; final bool verifying; final bool codeReady; final String? error; final VoidCallback onVerify; final VoidCallback onResend; String get _masked => phone.length == 11 ? '${phone.substring(0, 3)}****${phone.substring(7)}' : phone; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _SheetTitle('输入验证码', cs), const SizedBox(height: 8), _SheetSub('验证码已发送至 +86 $_masked', cs), const SizedBox(height: 20), _OtpInput(controller: codeCtrl, focusNode: codeFocus, cs: cs), if (error != null) ...[ const SizedBox(height: 12), _ErrorText(error!, cs), ], const SizedBox(height: 18), _PrimaryButton( label: '验证并登录', enabled: !verifying, loading: verifying, dim: !codeReady, onTap: onVerify, ), const SizedBox(height: 16), Center( child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: countdown > 0 ? null : onResend, child: Text( countdown > 0 ? '$countdown s 后可重新发送' : '重新发送', style: TextStyle( fontSize: 13, color: countdown > 0 ? cs.mutedForeground : cs.primary, ), ), ), ), ], ); } } class _SheetTitle extends StatelessWidget { const _SheetTitle(this.text, this.cs); final String text; final ShadColorScheme cs; @override Widget build(BuildContext context) => Text( text, style: TextStyle( fontSize: 19, fontWeight: FontWeight.w700, color: cs.foreground, ), ); } class _SheetSub extends StatelessWidget { const _SheetSub(this.text, this.cs); final String text; final ShadColorScheme cs; @override Widget build(BuildContext context) => Text( text, style: TextStyle(fontSize: 13, height: 1.55, color: cs.mutedForeground), ); } class _ErrorText extends StatelessWidget { const _ErrorText(this.text, this.cs); final String text; final ShadColorScheme cs; @override Widget build(BuildContext context) => Text(text, style: TextStyle(fontSize: 13, color: cs.destructive)); } class _BoxedField extends StatelessWidget { const _BoxedField({ required this.controller, required this.hint, required this.cs, }); final TextEditingController controller; final String hint; final ShadColorScheme cs; @override Widget build(BuildContext context) { return Container( height: 50, alignment: Alignment.center, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: cs.background, borderRadius: BorderRadius.circular(Spacing.radiusBtn), border: Border.all(color: cs.input, width: 1), ), child: TextField( controller: controller, keyboardType: TextInputType.phone, inputFormatters: [_PhoneNumberFormatter()], style: TextStyle( fontSize: 16, color: cs.foreground, fontFeatures: const [FontFeature.tabularFigures()], ), cursorColor: cs.primary, decoration: InputDecoration( isCollapsed: true, border: InputBorder.none, hintText: hint, hintStyle: TextStyle(fontSize: 15, color: cs.mutedForeground), ), ), ); } } class _PhoneNumberFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { var d = newValue.text.replaceAll(RegExp(r'\D'), ''); if (d.length > 11) d = d.substring(0, 11); final b = StringBuffer(); for (var i = 0; i < d.length; i++) { if (i == 3 || i == 7) b.write(' '); b.write(d[i]); } final s = b.toString(); return TextEditingValue( text: s, selection: TextSelection.collapsed(offset: s.length), ); } } class _OtpInput extends StatelessWidget { const _OtpInput({ required this.controller, required this.focusNode, required this.cs, }); final TextEditingController controller; final FocusNode focusNode; final ShadColorScheme cs; @override Widget build(BuildContext context) { final code = controller.text; return GestureDetector( behavior: HitTestBehavior.opaque, onTap: focusNode.requestFocus, child: SizedBox( height: 54, child: Stack( children: [ Row( children: [ for (var i = 0; i < 6; i++) ...[ if (i > 0) const SizedBox(width: 8), Expanded( child: _OtpCell( digit: i < code.length ? code[i] : '', filled: i < code.length, cs: cs, ), ), ], ], ), Positioned.fill( child: TextField( controller: controller, focusNode: focusNode, keyboardType: TextInputType.number, maxLength: 6, showCursor: false, autofocus: true, inputFormatters: [FilteringTextInputFormatter.digitsOnly], style: const TextStyle(color: Colors.transparent), decoration: const InputDecoration( counterText: '', border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, ), ), ), ], ), ), ); } } class _OtpCell extends StatelessWidget { const _OtpCell({required this.digit, required this.filled, required this.cs}); final String digit; final bool filled; final ShadColorScheme cs; @override Widget build(BuildContext context) { return Container( alignment: Alignment.center, decoration: BoxDecoration( color: cs.background, borderRadius: BorderRadius.circular(Spacing.radiusBtn), border: Border.all(color: filled ? cs.foreground : cs.input, width: 1), ), child: Text( digit, style: TextStyle( fontSize: 22, fontWeight: FontWeight.w600, color: cs.foreground, fontFeatures: const [FontFeature.tabularFigures()], ), ), ); } } class _AgreeRow extends StatelessWidget { const _AgreeRow({ required this.agreed, required this.onToggle, required this.cs, }); final bool agreed; final VoidCallback onToggle; final ShadColorScheme cs; @override Widget build(BuildContext context) { return GestureDetector( behavior: HitTestBehavior.opaque, onTap: onToggle, child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 18, height: 18, margin: const EdgeInsets.only(top: 1), decoration: BoxDecoration( borderRadius: BorderRadius.circular(Spacing.radiusBadge), border: Border.all( color: agreed ? cs.primary : cs.border, width: 1.2, ), color: agreed ? cs.primary : Colors.transparent, ), child: agreed ? Icon( FlutterRemix.check_line, size: 13, color: cs.primaryForeground, ) : null, ), const SizedBox(width: 8), Expanded(child: Text.rich(_policySpan(context, cs))), ], ), ); } } TextSpan _policySpan( BuildContext context, ShadColorScheme cs, { double fontSize = 11.5, }) { final isDark = Theme.of(context).brightness == Brightness.dark; final privacyUrl = AppH5UrlsHelper.buildUrlWithNight( AppH5Urls.privacyUrl, isDark, ); final protocolUrl = AppH5UrlsHelper.buildUrlWithNight( AppH5Urls.userProtocolUrl, isDark, ); TextSpan link(String text, String url) => TextSpan( text: text, style: TextStyle( color: cs.foreground, fontSize: fontSize, decoration: TextDecoration.underline, decorationColor: cs.foreground, ), recognizer: TapGestureRecognizer() ..onTap = () => launchUrlString( AppH5UrlsHelper.withCacheBuster(url), mode: LaunchMode.inAppBrowserView, ), ); return TextSpan( style: TextStyle( fontSize: fontSize, height: 1.6, color: cs.mutedForeground, ), children: [ const TextSpan(text: '已阅读并同意 '), link('《用户协议》', protocolUrl), const TextSpan(text: ' 与 '), link('《隐私政策》', privacyUrl), ], ); } class _PrimaryButton extends StatelessWidget { const _PrimaryButton({ required this.label, required this.enabled, required this.onTap, this.loading = false, this.dim = false, }); final String label; final bool enabled; final bool loading; final bool dim; final VoidCallback onTap; @override Widget build(BuildContext context) { final cs = ShadTheme.of(context).colorScheme; final active = enabled && !dim; return SizedBox( width: double.infinity, child: GestureDetector( onTap: enabled ? onTap : null, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(vertical: 15), alignment: Alignment.center, decoration: BoxDecoration( color: active ? cs.primary : cs.muted, borderRadius: BorderRadius.circular(9999), ), child: loading ? SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: active ? cs.primaryForeground : cs.mutedForeground, ), ) : Text( label, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, color: active ? cs.primaryForeground : cs.mutedForeground, ), ), ), ), ); } }