import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.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_v1.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 (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); } } Future _submit() async { if (!_agreed) { final accepted = await _showAgreementDialog(); if (!mounted || accepted != true) return; setState(() { _agreed = true; _error = null; }); } if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; } 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 { if (_selectedDoctorId == null) { setState(() { _loading = false; _error = '请选择医生'; }); return; } if (_nameCtrl.text.trim().isEmpty) { setState(() { _loading = false; _error = '请输入姓名'; }); return; } err = await ref .read(authProvider.notifier) .register( _phoneCtrl.text.trim(), _codeCtrl.text.trim(), _nameCtrl.text.trim(), _selectedDoctorId!, ); } setState(() => _loading = false); if (err != null) { if (err.contains('未注册')) setState(() => _isLogin = false); setState(() => _error = err); return; } if (!_isLogin) { setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _nameCtrl.clear(); _selectedDoctorId = null; _selectedDoctorName = null; }); ref.read(authProvider.notifier).logout(); } else { _goHome(); } } 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, 28, 22, 22), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(28), 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.w900, 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: BorderRadius.circular(18), boxShadow: AppColors.buttonShadow, ), child: const Text( '同意', style: TextStyle( color: Colors.white, fontSize: 16, fontWeight: FontWeight.w900, ), ), ), ), ), ], ), ], ), ), ), ); } void _goHome() { ref.invalidate(latestHealthProvider); ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); ref.invalidate(currentExercisePlanProvider); final role = ref.read(authProvider).user?.role ?? 'User'; if (role == 'Admin') { goRoute(ref, 'adminHome'); } else if (role == 'Doctor') { goRoute(ref, 'doctorHome'); } else { goRoute(ref, 'home'); } } void _showDoctorPicker() { showModalBottomSheet( context: context, backgroundColor: Colors.white, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), 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.all(18), child: Text( '请选择医生', style: TextStyle( fontSize: 18, fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), ), const Divider(height: 1, color: AppColors.divider), Expanded( child: ListView.builder( itemCount: active.length, itemBuilder: (_, i) { final d = active[i]; final name = d['name'] ?? ''; final title = d['title'] ?? ''; final dept = d['department'] ?? ''; return ListTile( 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 AppGradientIcon( icon: Icons.check_circle, size: 22, ) : 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) { final authState = ref.watch(authProvider); if (authState.isLoggedIn && !authState.isLoading) { WidgetsBinding.instance.addPostFrameCallback((_) => _goHome()); } return Scaffold( resizeToAvoidBottomInset: false, backgroundColor: Colors.white, body: Stack( children: [ Positioned.fill( 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.10), Colors.white.withValues(alpha: 0.28), Colors.white.withValues(alpha: 0.54), ], stops: const [0.0, 0.52, 1.0], ), ), ), ), SafeArea( child: LayoutBuilder( builder: (context, constraints) { return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(20, 28, 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: 28), _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: Icons.person_outline_rounded, ), const SizedBox(height: 12), ], _TextField( _phoneCtrl, '手机号', TextInputType.phone, 11, icon: Icons.local_phone_rounded, gradientIcon: true, ), const SizedBox(height: 12), Row( children: [ Expanded( child: _TextField( _codeCtrl, '验证码', TextInputType.number, 6, icon: Icons.sms_rounded, gradientIcon: true, ), ), 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, ), 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.w800, ), ), ), ), ], ), ), ], ), ), ), ), ); }, ), ), ], ), ); } } class _LoginHero extends StatelessWidget { const _LoginHero(); @override Widget build(BuildContext context) { return Column( children: [ _BrandMark(), const SizedBox(height: 16), const Text( '小脉健康', style: TextStyle( fontSize: 30, fontWeight: FontWeight.w900, 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.94), borderRadius: BorderRadius.circular(22), border: Border.all(color: AppColors.border, width: 1.1), boxShadow: [ BoxShadow( color: const Color(0xFF475467).withValues(alpha: 0.10), blurRadius: 30, offset: const Offset(0, 16), ), ], ), child: child, ); } } class _BrandMark extends StatelessWidget { @override Widget build(BuildContext context) { return SizedBox( width: 132, height: 132, child: Transform.scale( scale: 1.08, child: Image.asset( 'assets/branding/health_icon_master.png', fit: BoxFit.cover, ), ), ); } } class _TextField extends StatelessWidget { final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final IconData? icon; final bool gradientIcon; const _TextField( this.ctrl, this.hint, this.type, this.maxLen, { this.icon, this.gradientIcon = false, }); @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 : gradientIcon ? _GradientPrefixIcon(icon: icon!) : Icon(icon, size: 20, color: AppColors.textSecondary), filled: true, fillColor: const Color(0xFFF8FAFC), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15), enabledBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: AppColors.border, width: 1.1), ), focusedBorder: OutlineInputBorder( borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: AppColors.auraIndigo, width: 1.2), ), ), ); } class _GradientPrefixIcon extends StatelessWidget { final IconData icon; const _GradientPrefixIcon({required this.icon}); @override Widget build(BuildContext context) { return AppGradientIcon( icon: icon, size: 23, gradient: _LoginPageState._loginActionGradient, ); } } 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: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.border, width: 1.1), ), child: Row( children: [ const Icon( Icons.medical_services_outlined, 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(Icons.keyboard_arrow_down, 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( gradient: disabled ? null : _LoginPageState._loginActionGradient, color: disabled ? AppColors.cardInner : null, borderRadius: BorderRadius.circular(14), border: Border.all( color: disabled ? AppColors.border : Colors.transparent, ), ), child: Text( sending ? '发送中' : countdown > 0 ? '${countdown}s' : '获取验证码', style: TextStyle( fontSize: 13, fontWeight: FontWeight.w700, color: disabled ? AppColors.textSecondary : Colors.white, ), ), ), ); } } 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: BorderRadius.circular(5), 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: BorderRadius.circular(12), 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: BorderRadius.circular(14), 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.w800, ), ), ), ); } }