Files
AI-Health/health_app/lib/pages/auth/login_page.dart
2026-06-26 14:37:17 +08:00

910 lines
29 KiB
Dart

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';
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
static const _loginBg = 'assets/branding/login_background_v1.png';
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<void> _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<void> _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<bool?> _showAgreementDialog() {
return showDialog<bool>(
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: const TextSpan(
style: TextStyle(
fontSize: 15,
height: 1.55,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
children: [
TextSpan(text: '请阅读并同意'),
TextSpan(
text: '《服务协议》',
style: TextStyle(color: AppColors.primary),
),
TextSpan(text: ''),
TextSpan(
text: '《隐私政策》',
style: TextStyle(color: AppColors.primary),
),
],
),
),
const SizedBox(height: 26),
Row(
children: [
Expanded(
child: SizedBox(
height: 50,
child: OutlinedButton(
onPressed: () => Navigator.pop(ctx, false),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: BorderSide(
color: AppColors.primary.withValues(alpha: 0.62),
width: 1.2,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
textStyle: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
),
),
child: const Text('不同意'),
),
),
),
const SizedBox(width: 12),
Expanded(
child: GestureDetector(
onTap: () => Navigator.pop(ctx, true),
child: Container(
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: AppColors.doctorGradient,
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 Icon(
Icons.check_circle,
color: AppColors.primary,
)
: 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(
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),
),
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: Text(
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppColors.primary,
),
),
),
],
),
),
],
),
),
),
),
);
},
),
),
],
),
);
}
}
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.primary, width: 1.2),
),
),
);
}
class _GradientPrefixIcon extends StatelessWidget {
final IconData icon;
const _GradientPrefixIcon({required this.icon});
@override
Widget build(BuildContext context) {
return ShaderMask(
shaderCallback: AppColors.doctorGradient.createShader,
blendMode: BlendMode.srcIn,
child: Icon(icon, size: 23, color: Colors.white),
);
}
}
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 : AppColors.doctorGradient,
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;
const _Agreement({required this.agreed, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 18,
height: 18,
margin: const EdgeInsets.only(right: 7, top: 1),
decoration: BoxDecoration(
color: agreed ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(5),
border: Border.all(
color: agreed ? AppColors.primary : AppColors.border,
),
),
child: agreed
? const Icon(Icons.check, size: 13, color: Colors.white)
: null,
),
Flexible(
child: RichText(
text: const TextSpan(
children: [
TextSpan(
text: '已阅读并同意',
style: TextStyle(fontSize: 12, color: AppColors.textHint),
),
TextSpan(
text: '《服务协议》',
style: TextStyle(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
TextSpan(
text: '',
style: TextStyle(fontSize: 12, color: AppColors.textHint),
),
TextSpan(
text: '《隐私政策》',
style: TextStyle(
fontSize: 12,
color: AppColors.primary,
fontWeight: FontWeight.w700,
),
),
],
),
),
),
],
),
);
}
}
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: AppColors.doctorGradient,
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,
),
),
),
);
}
}