- 后端新增 Apple 登录端点 /api/auth/apple-login - 新增 AppleTokenValidator 验证 IdentityToken - User 实体添加 AppleUserId 字段,Phone 改为可空 - 前端添加 sign_in_with_apple 依赖和 Apple 登录按钮 - 统一 Bundle ID 为 com.datalumina.YYA,显示名称为小脉健康 - 配置 DEVELOPMENT_TEAM 和 Sign in with Apple Entitlements - 补充 NSCamera/NSPhotoLibrary 权限描述 - 生产 API 地址改为 https://erpapi.datalumina.cn/xiaomai - Flutter 升级至 3.44.6 / Dart 3.12.2 Co-Authored-By: Claude <noreply@anthropic.com>
986 lines
32 KiB
Dart
986 lines
32 KiB
Dart
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,
|
||
),
|
||
],
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|