fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者 - 注册页重构:去掉角色选择+审核码,改为选医生+填姓名 - 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立 - Doctor/DoctorProfile/User实体新增关联字段 - JSON循环引用修复(IgnoreCycles) - /api/doctors改为公开接口 - 登录闪屏修复+原生Android启动页 - 输入框全局白底+灰框 - 蓝牙重连同步修复 - Web端(doctor_web+health_app/web)全部删除 - 全局UI改版:白底企业风,新配色和组件系统 - 新品牌图标和启动图
This commit is contained in:
@@ -5,19 +5,18 @@ import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
const _doctorBlue = Color(0xFF0891B2);
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _codeCtrl = TextEditingController();
|
||||
final _inviteCtrl = TextEditingController();
|
||||
String _role = 'User';
|
||||
bool _showInvite = false;
|
||||
final _nameCtrl = TextEditingController();
|
||||
String? _selectedDoctorId;
|
||||
String? _selectedDoctorName;
|
||||
bool _agreed = false;
|
||||
bool _sending = false;
|
||||
int _countdown = 0;
|
||||
@@ -26,47 +25,104 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
bool _isLogin = true;
|
||||
String? _successMsg;
|
||||
|
||||
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _inviteCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Color get _accent => _role == 'Doctor' ? _doctorBlue : AppColors.primary;
|
||||
@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; });
|
||||
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; }
|
||||
setState(() => _sending = false);
|
||||
if (result.error != null) {
|
||||
setState(() => _error = result.error);
|
||||
return;
|
||||
}
|
||||
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||
setState(() => _countdown = 60); _startCountdown();
|
||||
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); }
|
||||
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) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
|
||||
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; }
|
||||
setState(() { _loading = true; _error = null; _successMsg = null; });
|
||||
if (!_agreed) {
|
||||
setState(() => _error = '请阅读并同意服务协议和隐私政策');
|
||||
return;
|
||||
}
|
||||
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());
|
||||
err = await ref
|
||||
.read(authProvider.notifier)
|
||||
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||
} else {
|
||||
if (_role == 'Doctor' && _inviteCtrl.text.trim() != '6666') { setState(() { _loading = false; _error = '审核码错误'; }); return; }
|
||||
err = await ref.read(authProvider.notifier).register(_phoneCtrl.text.trim(), _codeCtrl.text.trim(), _role, inviteCode: _role == 'Doctor' ? _inviteCtrl.text.trim() : null);
|
||||
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; }); }
|
||||
if (err.contains('未注册')) setState(() => _isLogin = false);
|
||||
setState(() => _error = err);
|
||||
return;
|
||||
}
|
||||
if (!_isLogin) {
|
||||
// 注册成功 → 回登录页
|
||||
setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _inviteCtrl.clear(); _showInvite = false; });
|
||||
setState(() {
|
||||
_isLogin = true;
|
||||
_successMsg = '注册成功,请登录';
|
||||
_error = null;
|
||||
_phoneCtrl.clear();
|
||||
_codeCtrl.clear();
|
||||
_nameCtrl.clear();
|
||||
_selectedDoctorId = null;
|
||||
_selectedDoctorName = null;
|
||||
});
|
||||
ref.read(authProvider.notifier).logout();
|
||||
} else {
|
||||
_goHome();
|
||||
@@ -79,95 +135,216 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
final role = ref.read(authProvider).user?.role ?? 'User';
|
||||
goRoute(ref, role == 'Doctor' ? 'doctorHome' : 'home');
|
||||
if (role == 'Admin') {
|
||||
goRoute(ref, 'adminHome');
|
||||
} else if (role == 'Doctor') {
|
||||
goRoute(ref, 'doctorHome');
|
||||
} else {
|
||||
goRoute(ref, 'home');
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
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: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const SizedBox(height: 30),
|
||||
Icon(Icons.favorite, size: 48, color: _accent),
|
||||
const SizedBox(height: 12),
|
||||
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (_successMsg != null)
|
||||
Padding(padding: const EdgeInsets.only(bottom: 16), child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFD1FAE5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.check_circle, color: Color(0xFF059669), size: 18), const SizedBox(width: 8), Text(_successMsg!, style: const TextStyle(color: Color(0xFF059669), fontSize: 14))]))),
|
||||
|
||||
// 角色选择(注册模式)
|
||||
if (!_isLogin) ...[
|
||||
Row(children: [
|
||||
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// 手机号
|
||||
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 验证码
|
||||
Row(children: [
|
||||
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
||||
child: Container(
|
||||
width: 110, height: 50, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 22),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
]),
|
||||
|
||||
if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)],
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 协议
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _agreed = !_agreed),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 18, height: 18, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: _agreed ? _accent : Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all(color: _agreed ? _accent : AppColors.border)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||
RichText(text: TextSpan(children: [const TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: _accent)), const TextSpan(text: '和', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: _accent))])),
|
||||
]),
|
||||
),
|
||||
|
||||
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 提交按钮
|
||||
GestureDetector(
|
||||
onTap: _loading ? null : _submit,
|
||||
child: Container(
|
||||
width: double.infinity, height: 52, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
|
||||
child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : Text(_isLogin ? '登 录' : '注 册', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 4)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BrandMark(),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'健康管家',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_isLogin ? '登录你的健康账户' : '创建新的健康账户',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
if (_successMsg != null)
|
||||
_NoticeBanner(text: _successMsg!, success: true),
|
||||
if (!_isLogin) ...[
|
||||
_DoctorSelector(
|
||||
name: _selectedDoctorName,
|
||||
onTap: _showDoctorPicker,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_TextField(_nameCtrl, '姓名', TextInputType.name, 20),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TextField(
|
||||
_codeCtrl,
|
||||
'验证码',
|
||||
TextInputType.number,
|
||||
6,
|
||||
),
|
||||
),
|
||||
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 ? '没有账号?去注册' : '已有账号?去登录',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; if (_isLogin) _showInvite = false; }),
|
||||
child: Text(_isLogin ? '没有账号?去注册' : '已有账号?去登录', style: TextStyle(fontSize: 14, color: _accent)),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -175,29 +352,269 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.monitor_heart_outlined,
|
||||
color: Colors.white,
|
||||
size: 34,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextField extends StatelessWidget {
|
||||
final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent;
|
||||
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent);
|
||||
@override Widget build(BuildContext context) => Container(
|
||||
height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)),
|
||||
child: TextField(
|
||||
controller: ctrl, keyboardType: type, maxLength: maxLen,
|
||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, counterText: '', contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13)),
|
||||
),
|
||||
final TextEditingController ctrl;
|
||||
final String hint;
|
||||
final TextInputType type;
|
||||
final int maxLen;
|
||||
const _TextField(this.ctrl, this.hint, this.type, this.maxLen);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextField(
|
||||
controller: ctrl,
|
||||
keyboardType: type,
|
||||
maxLength: maxLen,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(hintText: hint, counterText: ''),
|
||||
);
|
||||
}
|
||||
|
||||
class _RoleCard extends StatelessWidget {
|
||||
final bool selected; final IconData icon; final String label, desc; final Color color; final VoidCallback onTap;
|
||||
const _RoleCard({required this.selected, required this.icon, required this.label, required this.desc, required this.color, required this.onTap});
|
||||
@override Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)),
|
||||
child: Column(children: [Icon(icon, size: 32, color: selected ? Colors.white : AppColors.textHint), const SizedBox(height: 6), Text(label, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: selected ? Colors.white : AppColors.textPrimary)), Text(desc, style: TextStyle(fontSize: 12, color: selected ? Colors.white70 : AppColors.textHint))]),
|
||||
),
|
||||
);
|
||||
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: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name ?? '请选择医生',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
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(
|
||||
color: disabled ? AppColors.cardInner : AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: disabled ? AppColors.border : AppColors.primary,
|
||||
),
|
||||
),
|
||||
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(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
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),
|
||||
),
|
||||
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.primaryGradient,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user