feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试

## 后端
- 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法
- 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展
- 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整
- 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景
- 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展
- AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理
- Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整
- BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑
- 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试

## 前端
- UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新
- 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构
- 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取
- 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化
- 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化
- 趋势图: trend_page 大幅重构
- 登录: login_page 重构
- 个人资料: 新增 profile_edit_page; profile_page 优化
- 运动: 新增 exercise/ 目录 + care_plan_ui_logic
- 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整
- 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化
- Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整
- AndroidManifest: 移除多余权限
- 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试

## 文档
- 新增 ui-design-system.md 设计系统文档
- 新增 secondary-page-color-refresh 计划 + specs 目录
This commit is contained in:
MingNian
2026-07-15 23:22:52 +08:00
parent e654c1e0cc
commit fade61ac21
138 changed files with 12636 additions and 5013 deletions

View File

@@ -1,6 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
@@ -57,7 +60,8 @@ class _LoginPageState extends ConsumerState<LoginPage> {
setState(() => _error = result.error);
return;
}
if (result.devCode != null) _codeCtrl.text = result.devCode!;
if (!mounted) return;
if (kDebugMode && result.devCode != null) _codeCtrl.text = result.devCode!;
setState(() => _countdown = 60);
_startCountdown();
}
@@ -70,6 +74,18 @@ class _LoginPageState extends ConsumerState<LoginPage> {
}
Future<void> _submit() async {
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
setState(() => _error = '请输入手机号和验证码');
return;
}
if (!_isLogin && _nameCtrl.text.trim().isEmpty) {
setState(() => _error = '请输入姓名');
return;
}
if (!_isLogin && _selectedDoctorId == null) {
setState(() => _error = '请选择健康服务医生');
return;
}
if (!_agreed) {
final accepted = await _showAgreementDialog();
if (!mounted || accepted != true) return;
@@ -78,10 +94,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
_error = null;
});
}
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
setState(() => _error = '请输入手机号和验证码');
return;
}
setState(() {
_loading = true;
_error = null;
@@ -94,20 +106,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
.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(
@@ -118,27 +116,14 @@ class _LoginPageState extends ConsumerState<LoginPage> {
);
}
if (!mounted) return;
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() {
@@ -152,10 +137,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 380),
padding: const EdgeInsets.fromLTRB(22, 28, 22, 22),
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(28),
borderRadius: AppRadius.xlBorder,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.14),
@@ -247,7 +232,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: _loginActionGradient,
borderRadius: BorderRadius.circular(18),
borderRadius: AppRadius.mdBorder,
boxShadow: AppColors.buttonShadow,
),
child: const Text(
@@ -270,27 +255,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
);
}
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,
showDragHandle: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
),
builder: (ctx) => Consumer(
builder: (ctx, ref, _) {
@@ -311,9 +282,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
child: Column(
children: [
const Padding(
padding: EdgeInsets.all(18),
padding: EdgeInsets.fromLTRB(20, 2, 20, 16),
child: Text(
'选择医生',
'选择健康服务医生',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
@@ -321,16 +292,34 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
),
),
const Divider(height: 1, color: AppColors.divider),
Expanded(
child: ListView.builder(
child: ListView.separated(
padding: const EdgeInsets.symmetric(horizontal: 20),
itemCount: active.length,
separatorBuilder: (_, _) => const Padding(
padding: EdgeInsets.only(left: 50),
child: Divider(height: 1, color: AppColors.divider),
),
itemBuilder: (_, i) {
final d = active[i];
final name = d['name'] ?? '';
final title = d['title'] ?? '';
final dept = d['department'] ?? '';
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: AppRadius.smBorder,
),
child: const Icon(
LucideIcons.stethoscope,
size: 20,
color: AppColors.primaryDark,
),
),
title: Text(
name,
style: const TextStyle(
@@ -345,9 +334,10 @@ class _LoginPageState extends ConsumerState<LoginPage> {
),
),
trailing: _selectedDoctorId == d['id']?.toString()
? const AppGradientIcon(
icon: Icons.check_circle,
? const Icon(
LucideIcons.circleCheck,
size: 22,
color: AppColors.primaryDark,
)
: null,
onTap: () {
@@ -379,11 +369,6 @@ class _LoginPageState extends ConsumerState<LoginPage> {
@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,
@@ -403,140 +388,146 @@ class _LoginPageState extends ConsumerState<LoginPage> {
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),
Colors.white.withValues(alpha: 0.02),
Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.40),
],
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,
AnimatedPadding(
duration: const Duration(milliseconds: 180),
curve: Curves.easeOutCubic,
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
),
child: SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 22, 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,
),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 430),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _LoginHero(),
const SizedBox(height: 22),
_LoginFormCard(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (_successMsg != null)
_NoticeBanner(
text: _successMsg!,
success: true,
),
const SizedBox(width: 10),
_SmsButton(
sending: _sending,
countdown: _countdown,
onTap: (_countdown > 0 || _sending)
? null
: _sendSms,
if (!_isLogin) ...[
_DoctorSelector(
name: _selectedDoctorName,
onTap: _showDoctorPicker,
),
const SizedBox(height: 12),
_TextField(
_nameCtrl,
'姓名',
TextInputType.name,
20,
icon: LucideIcons.userRound,
),
const SizedBox(height: 12),
],
_TextField(
_phoneCtrl,
'手机号',
TextInputType.phone,
11,
icon: LucideIcons.phone,
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _TextField(
_codeCtrl,
'验证码',
TextInputType.number,
6,
icon: LucideIcons.messageSquareText,
),
),
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: 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: 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,
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,
),
),
),
),
),
],
],
),
),
),
],
],
),
),
),
),
),
);
},
);
},
),
),
),
],
@@ -553,11 +544,11 @@ class _LoginHero extends StatelessWidget {
return Column(
children: [
_BrandMark(),
const SizedBox(height: 16),
const SizedBox(height: 12),
const Text(
'小脉健康',
style: TextStyle(
fontSize: 30,
fontSize: 28,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
letterSpacing: 0,
@@ -589,14 +580,13 @@ class _LoginFormCard extends StatelessWidget {
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),
color: Colors.white.withValues(alpha: 0.88),
borderRadius: AppRadius.xlBorder,
boxShadow: [
BoxShadow(
color: const Color(0xFF475467).withValues(alpha: 0.10),
blurRadius: 30,
offset: const Offset(0, 16),
color: const Color(0xFF475467).withValues(alpha: 0.07),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
),
@@ -609,10 +599,22 @@ class _BrandMark extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SizedBox(
width: 132,
height: 132,
child: Transform.scale(
scale: 1.08,
width: 108,
height: 108,
child: Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
shape: BoxShape.circle,
border: Border.all(color: Colors.white.withValues(alpha: 0.86)),
boxShadow: [
BoxShadow(
color: AppColors.primary.withValues(alpha: 0.10),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child: Image.asset(
'assets/branding/health_icon_master.png',
fit: BoxFit.cover,
@@ -628,16 +630,8 @@ class _TextField extends StatelessWidget {
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,
});
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, {this.icon});
@override
Widget build(BuildContext context) => TextField(
@@ -654,38 +648,22 @@ class _TextField extends StatelessWidget {
counterText: '',
prefixIcon: icon == null
? null
: gradientIcon
? _GradientPrefixIcon(icon: icon!)
: Icon(icon, size: 20, color: AppColors.textSecondary),
: Icon(icon, size: 20, color: AppColors.primaryDark),
filled: true,
fillColor: const Color(0xFFF8FAFC),
fillColor: AppColors.cardInner,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 15),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.border, width: 1.1),
borderRadius: AppRadius.mdBorder,
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.auraIndigo, width: 1.2),
borderRadius: AppRadius.mdBorder,
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 AppGradientIcon(
icon: icon,
size: 23,
gradient: _LoginPageState._loginActionGradient,
);
}
}
class _DoctorSelector extends StatelessWidget {
final String? name;
final VoidCallback onTap;
@@ -699,21 +677,20 @@ class _DoctorSelector extends StatelessWidget {
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),
color: AppColors.cardInner,
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
const Icon(
Icons.medical_services_outlined,
LucideIcons.stethoscope,
size: 20,
color: AppColors.textSecondary,
),
const SizedBox(width: 12),
Expanded(
child: Text(
name ?? '请选择医生',
name ?? '请选择健康服务医生(必选)',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
@@ -723,7 +700,11 @@ class _DoctorSelector extends StatelessWidget {
),
),
),
const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint),
const Icon(
LucideIcons.chevronDown,
size: 19,
color: AppColors.textHint,
),
],
),
),
@@ -751,11 +732,10 @@ class _SmsButton extends StatelessWidget {
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: disabled ? null : _LoginPageState._loginActionGradient,
color: disabled ? AppColors.cardInner : null,
borderRadius: BorderRadius.circular(14),
color: disabled ? AppColors.cardInner : Colors.white,
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: disabled ? AppColors.border : Colors.transparent,
color: disabled ? AppColors.borderLight : AppColors.primaryLight,
),
),
child: Text(
@@ -767,7 +747,7 @@ class _SmsButton extends StatelessWidget {
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: disabled ? AppColors.textSecondary : Colors.white,
color: disabled ? AppColors.textSecondary : AppColors.primaryDark,
),
),
),
@@ -805,7 +785,7 @@ class _Agreement extends StatelessWidget {
decoration: BoxDecoration(
gradient: agreed ? _LoginPageState._loginActionGradient : null,
color: agreed ? null : Colors.white,
borderRadius: BorderRadius.circular(5),
borderRadius: AppRadius.xsBorder,
border: Border.all(
color: agreed ? Colors.transparent : AppColors.border,
),
@@ -856,7 +836,7 @@ class _NoticeBanner extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: success ? AppColors.successLight : AppColors.errorLight,
borderRadius: BorderRadius.circular(12),
borderRadius: AppRadius.mdBorder,
border: Border.all(
color: success
? AppColors.success.withValues(alpha: 0.16)
@@ -907,7 +887,7 @@ class _PrimaryButton extends StatelessWidget {
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: _LoginPageState._loginActionGradient,
borderRadius: BorderRadius.circular(14),
borderRadius: AppRadius.mdBorder,
boxShadow: AppColors.buttonShadow,
),
child: loading