import 'dart:io' show Platform; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:sign_in_with_apple/sign_in_with_apple.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_gradient_widgets.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { static const _loginBg = 'assets/branding/login_background_v2.png'; static const _loginActionGradient = AppColors.primaryGradient; void _openStaticText(String type) { pushRoute(ref, 'staticText', params: {'type': type}); } final _phoneCtrl = TextEditingController(); final _codeCtrl = TextEditingController(); final _nameCtrl = TextEditingController(); String? _selectedDoctorId; String? _selectedDoctorName; bool _agreed = false; bool _sending = false; int _countdown = 0; bool _loading = false; String? _error; bool _isLogin = true; String? _successMsg; @override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _nameCtrl.dispose(); super.dispose(); } Future _sendSms() async { final phone = _phoneCtrl.text.trim(); if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; } setState(() { _sending = true; _error = null; }); final result = await ref.read(authProvider.notifier).sendSms(phone); setState(() => _sending = false); if (result.error != null) { setState(() => _error = result.error); return; } if (!mounted) return; if (kDebugMode && result.devCode != null) _codeCtrl.text = result.devCode!; setState(() => _countdown = 60); _startCountdown(); } void _startCountdown() async { for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); } } // Apple 登录 Future _appleSignIn() async { if (_loading) return; if (!_agreed) { final accepted = await _showAgreementDialog(); if (!mounted || accepted != true) return; setState(() => _agreed = true); } setState(() { _loading = true; _error = null; _successMsg = null; }); try { final credential = await SignInWithApple.getAppleIDCredential( scopes: [AppleIDAuthorizationScopes.fullName], ); final identityToken = credential.identityToken; if (identityToken == null) { if (mounted) { setState(() { _loading = false; _error = 'Apple 登录未返回身份信息,请重试'; }); } return; } final name = '${credential.familyName ?? ''}${credential.givenName ?? ''}' .trim(); final err = await ref .read(authProvider.notifier) .appleLogin( identityToken, credential.authorizationCode, name.isEmpty ? null : name, ); if (mounted) { setState(() => _loading = false); if (err != null) { setState(() => _error = err); } } } catch (e) { if (mounted) { setState(() { _loading = false; if (e is SignInWithAppleAuthorizationException) { _error = 'Apple 登录已取消'; } else { _error = 'Apple 登录失败,请稍后重试'; } }); } } } Future _submit() async { if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; } if (!_isLogin && _nameCtrl.text.trim().isEmpty) { setState(() => _error = '请输入姓名'); return; } if (!_isLogin && _selectedDoctorId == null) { setState(() => _error = '请选择健康服务医生'); return; } if (!_agreed) { final accepted = await _showAgreementDialog(); if (!mounted || accepted != true) return; setState(() { _agreed = true; _error = null; }); } setState(() { _loading = true; _error = null; _successMsg = null; }); String? err; if (_isLogin) { err = await ref .read(authProvider.notifier) .login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); } else { err = await ref .read(authProvider.notifier) .register( _phoneCtrl.text.trim(), _codeCtrl.text.trim(), _nameCtrl.text.trim(), _selectedDoctorId!, ); } if (!mounted) return; setState(() => _loading = false); if (err != null) { if (err.contains('未注册')) setState(() => _isLogin = false); setState(() => _error = err); return; } // 登录和注册成功后的页面切换统一由根导航监听认证状态处理。 } Future _showAgreementDialog() { return showDialog( context: context, barrierDismissible: false, barrierColor: Colors.black.withValues(alpha: 0.54), builder: (ctx) => Dialog( insetPadding: const EdgeInsets.symmetric(horizontal: 28), backgroundColor: Colors.transparent, child: Container( width: double.infinity, constraints: const BoxConstraints(maxWidth: 380), padding: const EdgeInsets.fromLTRB(22, 26, 22, 22), decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.xlBorder, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.14), blurRadius: 34, offset: const Offset(0, 18), ), ], ), child: Column( mainAxisSize: MainAxisSize.min, children: [ const Text( '服务协议及隐私保护', textAlign: TextAlign.center, style: TextStyle( fontSize: 21, fontWeight: FontWeight.w700, color: AppColors.textPrimary, letterSpacing: 0, ), ), const SizedBox(height: 18), RichText( textAlign: TextAlign.center, text: TextSpan( style: const TextStyle( fontSize: 15, height: 1.55, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), children: [ const TextSpan(text: '请阅读并同意'), WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: GestureDetector( onTap: () { Navigator.pop(ctx, false); _openStaticText('terms'); }, child: AppGradientText( '《服务协议》', style: TextStyle( fontSize: 15, height: 1.55, fontWeight: FontWeight.w700, ), ), ), ), const TextSpan(text: '和'), WidgetSpan( alignment: PlaceholderAlignment.baseline, baseline: TextBaseline.alphabetic, child: GestureDetector( onTap: () { Navigator.pop(ctx, false); _openStaticText('privacy'); }, child: AppGradientText( '《隐私政策》', style: TextStyle( fontSize: 15, height: 1.55, fontWeight: FontWeight.w700, ), ), ), ), ], ), ), const SizedBox(height: 26), Row( children: [ Expanded( child: AppGradientOutlineButton( label: '不同意', onTap: () => Navigator.pop(ctx, false), ), ), const SizedBox(width: 12), Expanded( child: GestureDetector( onTap: () => Navigator.pop(ctx, true), child: Container( height: 50, alignment: Alignment.center, decoration: BoxDecoration( gradient: _loginActionGradient, borderRadius: AppRadius.mdBorder, boxShadow: AppColors.buttonShadow, ), child: const Text( '同意', style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w700, ), ), ), ), ), ], ), ], ), ), ), ); } void _showDoctorPicker() { showModalBottomSheet( context: context, backgroundColor: Colors.white, showDragHandle: true, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)), ), builder: (ctx) => Consumer( builder: (ctx, ref, _) { final doctorsAsync = ref.watch(doctorListProvider); return doctorsAsync.when( data: (doctors) { final active = doctors .where((d) => d['isActive'] != false) .toList(); if (active.isEmpty) { return const SizedBox( height: 200, child: Center(child: Text('暂无可用医生')), ); } return SizedBox( height: 420, child: Column( children: [ const Padding( padding: EdgeInsets.fromLTRB(20, 2, 20, 16), child: Text( '选择健康服务医生', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary, ), ), ), Expanded( child: ListView.separated( padding: const EdgeInsets.symmetric(horizontal: 20), itemCount: active.length, separatorBuilder: (_, _) => const Padding( padding: EdgeInsets.only(left: 50), child: Divider(height: 1, color: AppColors.divider), ), itemBuilder: (_, i) { final d = active[i]; final name = d['name'] ?? ''; final title = d['title'] ?? ''; final dept = d['department'] ?? ''; return ListTile( contentPadding: EdgeInsets.zero, leading: Container( width: 38, height: 38, decoration: BoxDecoration( color: AppColors.primarySoft, borderRadius: AppRadius.smBorder, ), child: const Icon( LucideIcons.stethoscope, size: 20, color: AppColors.primaryDark, ), ), title: Text( name, style: const TextStyle( fontWeight: FontWeight.w700, ), ), subtitle: Text( '$title · $dept', style: const TextStyle( fontSize: 13, color: AppColors.textSecondary, ), ), trailing: _selectedDoctorId == d['id']?.toString() ? const Icon( LucideIcons.circleCheck, size: 22, color: AppColors.primaryDark, ) : null, onTap: () { setState(() { _selectedDoctorId = d['id']?.toString(); _selectedDoctorName = name; }); Navigator.pop(ctx); }, ); }, ), ), ], ), ); }, loading: () => const SizedBox( height: 200, child: Center(child: CircularProgressIndicator()), ), error: (_, _) => const SizedBox(height: 200, child: Center(child: Text('加载失败'))), ); }, ), ); } @override Widget build(BuildContext context) { return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.white, body: Stack( children: [ Positioned.fill( child: RepaintBoundary( child: Image.asset( _loginBg, fit: BoxFit.cover, alignment: Alignment.topCenter, ), ), ), Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.white.withValues(alpha: 0.02), Colors.white.withValues(alpha: 0.12), Colors.white.withValues(alpha: 0.40), ], stops: const [0.0, 0.52, 1.0], ), ), ), ), _LoginKeyboardLift( child: SafeArea( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.fromLTRB(20, 22, 20, 24), child: ConstrainedBox( constraints: BoxConstraints( minHeight: (constraints.maxHeight - 52).clamp( 0.0, double.infinity, ), ), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 430), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const _LoginHero(), const SizedBox(height: 22), _LoginFormCard( child: Column( mainAxisSize: MainAxisSize.min, children: [ if (_successMsg != null) _NoticeBanner( text: _successMsg!, success: true, ), if (!_isLogin) ...[ _DoctorSelector( name: _selectedDoctorName, onTap: _showDoctorPicker, ), const SizedBox(height: 12), _TextField( _nameCtrl, '姓名', TextInputType.name, 20, icon: LucideIcons.userRound, ), const SizedBox(height: 12), ], _TextField( _phoneCtrl, '手机号', TextInputType.phone, 11, icon: LucideIcons.phone, ), const SizedBox(height: 12), Row( children: [ Expanded( child: _TextField( _codeCtrl, '验证码', TextInputType.number, 6, icon: LucideIcons.messageSquareText, ), ), const SizedBox(width: 10), _SmsButton( sending: _sending, countdown: _countdown, onTap: (_countdown > 0 || _sending) ? null : _sendSms, ), ], ), const SizedBox(height: 14), _Agreement( agreed: _agreed, onTap: () => setState(() => _agreed = !_agreed), onTermsTap: () => _openStaticText('terms'), onPrivacyTap: () => _openStaticText('privacy'), ), if (_error != null) ...[ const SizedBox(height: 12), _NoticeBanner( text: _error!, success: false, ), ], const SizedBox(height: 22), _PrimaryButton( loading: _loading, label: _isLogin ? '登录' : '注册', onTap: _loading ? null : _submit, ), if (_isLogin && (Platform.isIOS || Platform.isMacOS)) ...[ const SizedBox(height: 20), SignInWithAppleButton( onPressed: _loading ? () {} : _appleSignIn, style: SignInWithAppleButtonStyle .whiteOutlined, height: 48, text: '通过 Apple 登录', ), ], const SizedBox(height: 16), GestureDetector( onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; }), child: Center( child: AppGradientText( _isLogin ? '没有账号?去注册' : '已有账号?去登录', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, ), ), ), ), ], ), ), ], ), ), ), ), ); }, ), ), ), ], ), ); } } class _LoginKeyboardLift extends StatelessWidget { final Widget child; const _LoginKeyboardLift({required this.child}); @override Widget build(BuildContext context) { final keyboardHeight = MediaQuery.viewInsetsOf(context).bottom; final lift = (keyboardHeight * 0.42).clamp(0.0, 150.0); return Transform.translate( offset: Offset(0, -lift), transformHitTests: true, child: RepaintBoundary(child: child), ); } } class _LoginHero extends StatelessWidget { const _LoginHero(); @override Widget build(BuildContext context) { return Column( children: [ _BrandMark(), const SizedBox(height: 12), const Text( '小脉健康', style: TextStyle( fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary, letterSpacing: 0, ), ), const SizedBox(height: 6), const Text( '血管病患者的 AI 健康管理助手', textAlign: TextAlign.center, style: TextStyle( fontSize: 14, height: 1.45, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), ), ], ); } } class _LoginFormCard extends StatelessWidget { final Widget child; const _LoginFormCard({required this.child}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(18, 18, 18, 20), decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.88), borderRadius: AppRadius.xlBorder, boxShadow: [ BoxShadow( color: const Color(0xFF475467).withValues(alpha: 0.07), blurRadius: 24, offset: const Offset(0, 12), ), ], ), child: child, ); } } class _BrandMark extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( width: 116, height: 116, child: Image.asset( 'assets/branding/health_login_character_transparent.png', fit: BoxFit.contain, ), ); } } class _TextField extends StatelessWidget { final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final IconData? icon; const _TextField(this.ctrl, this.hint, this.type, this.maxLen, {this.icon}); @override Widget build(BuildContext context) => TextField( controller: ctrl, keyboardType: type, maxLength: maxLen, style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary, ), decoration: InputDecoration( hintText: hint, counterText: '', prefixIcon: icon == null ? null : Icon(icon, size: 20, color: AppColors.primaryDark), filled: true, fillColor: AppColors.cardInner, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15), enabledBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, borderSide: BorderSide.none, ), focusedBorder: OutlineInputBorder( borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: AppColors.primary, width: 1.2), ), ), ); } class _DoctorSelector extends StatelessWidget { final String? name; final VoidCallback onTap; const _DoctorSelector({required this.name, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( height: 50, padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: AppColors.cardInner, borderRadius: AppRadius.mdBorder, ), child: Row( children: [ const Icon( LucideIcons.stethoscope, size: 20, color: AppColors.textSecondary, ), const SizedBox(width: 12), Expanded( child: Text( name ?? '请选择健康服务医生(必选)', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, color: name != null ? AppColors.textPrimary : AppColors.textHint, ), ), ), const Icon( LucideIcons.chevronDown, size: 19, color: AppColors.textHint, ), ], ), ), ); } } class _SmsButton extends StatelessWidget { final bool sending; final int countdown; final VoidCallback? onTap; const _SmsButton({ required this.sending, required this.countdown, required this.onTap, }); @override Widget build(BuildContext context) { final disabled = countdown > 0 || sending; return GestureDetector( onTap: onTap, child: Container( width: 108, height: 50, alignment: Alignment.center, decoration: BoxDecoration( color: disabled ? AppColors.cardInner : Colors.white, borderRadius: AppRadius.mdBorder, border: Border.all( color: disabled ? AppColors.borderLight : AppColors.primaryLight, ), ), child: Text( sending ? '发送中' : countdown > 0 ? '${countdown}s' : '获取验证码', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: disabled ? AppColors.textSecondary : AppColors.primaryDark, ), ), ), ); } } class _Agreement extends StatelessWidget { final bool agreed; final VoidCallback onTap; final VoidCallback onTermsTap; final VoidCallback onPrivacyTap; const _Agreement({ required this.agreed, required this.onTap, required this.onTermsTap, required this.onPrivacyTap, }); @override Widget build(BuildContext context) { const textStyle = TextStyle(fontSize: 13, color: AppColors.textHint); const linkStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w700); return Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( width: 18, height: 18, margin: const EdgeInsets.only(right: 7), decoration: BoxDecoration( gradient: agreed ? _LoginPageState._loginActionGradient : null, color: agreed ? null : Colors.white, borderRadius: AppRadius.xsBorder, border: Border.all( color: agreed ? Colors.transparent : AppColors.border, ), ), child: agreed ? const Icon(Icons.check, size: 13, color: Colors.white) : null, ), ), Expanded( child: Wrap( crossAxisAlignment: WrapCrossAlignment.center, children: [ GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: const Text('已阅读并同意', style: textStyle), ), GestureDetector( onTap: onTermsTap, behavior: HitTestBehavior.opaque, child: const AppGradientText('《服务协议》', style: linkStyle), ), const Text('和', style: textStyle), GestureDetector( onTap: onPrivacyTap, behavior: HitTestBehavior.opaque, child: const AppGradientText('《隐私政策》', style: linkStyle), ), ], ), ), ], ); } } class _NoticeBanner extends StatelessWidget { final String text; final bool success; const _NoticeBanner({required this.text, required this.success}); @override Widget build(BuildContext context) { return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: success ? AppColors.successLight : AppColors.errorLight, borderRadius: AppRadius.mdBorder, border: Border.all( color: success ? AppColors.success.withValues(alpha: 0.16) : AppColors.error.withValues(alpha: 0.16), ), ), child: Row( children: [ Icon( success ? Icons.check_circle : Icons.error_outline, color: success ? AppColors.success : AppColors.error, size: 18, ), const SizedBox(width: 8), Expanded( child: Text( text, style: TextStyle( color: success ? AppColors.success : AppColors.error, fontSize: 13, fontWeight: FontWeight.w600, ), ), ), ], ), ); } } class _PrimaryButton extends StatelessWidget { final bool loading; final String label; final VoidCallback? onTap; const _PrimaryButton({ required this.loading, required this.label, required this.onTap, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, child: Container( width: double.infinity, height: 52, alignment: Alignment.center, decoration: BoxDecoration( gradient: _LoginPageState._loginActionGradient, borderRadius: AppRadius.mdBorder, boxShadow: AppColors.buttonShadow, ), child: loading ? const SizedBox( width: 23, height: 23, child: CircularProgressIndicator( strokeWidth: 2.5, color: Colors.white, ), ) : Text( label, style: const TextStyle( fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, ), ), ), ); } }