Files
AI-Health/health_app/lib/pages/auth/login_page.dart
sccsbc bd4350c17f fix: 修复 Apple 登录 sub claim 映射 + 按钮文案中文化
- AppleTokenValidator: 从 JwtSecurityToken 原始 Claims 取 sub,避免 .NET ClaimType 映射导致丢失
- 登录页 Apple 按钮改为中文"通过 Apple 登录"

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-10 13:35:28 +08:00

987 lines
32 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io' show Platform;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:sign_in_with_apple/sign_in_with_apple.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);
}
}
// Apple 登录
Future<void> _appleSignIn() async {
if (_loading) return;
setState(() {
_loading = true;
_error = null;
_successMsg = null;
});
try {
final credential = await SignInWithApple.getAppleIDCredential(
scopes: [
AppleIDAuthorizationScopes.fullName,
],
webAuthenticationOptions: WebAuthenticationOptions(
clientId: 'com.datalumina.YYA.signin',
redirectUri: Uri.parse(
'https://erpapi.datalumina.cn/xiaomai/api/auth/apple-login',
),
),
);
final identityToken = credential.identityToken;
if (identityToken == null) {
if (mounted) {
setState(() {
_loading = false;
_error = 'Apple 登录未返回身份信息,请重试';
});
}
return;
}
final name = credential.givenName != null && credential.familyName != null
? '${credential.familyName}${credential.givenName}'
: null;
final err = await ref.read(authProvider.notifier).appleLogin(
identityToken,
credential.authorizationCode,
name,
);
if (mounted) {
setState(() => _loading = false);
if (err != null) {
setState(() => _error = err);
} else {
_goHome();
}
}
} catch (e) {
if (mounted) {
setState(() {
_loading = false;
if (e is SignInWithAppleAuthorizationException) {
_error = 'Apple 登录已取消';
} else {
_error = 'Apple 登录失败: $e';
}
});
}
}
}
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,
),
// Apple 登录按钮(仅 iOS/macOS
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: 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,
),
),
),
);
}
}