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

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://192.168.1.34:5000',
defaultValue: 'http://10.4.237.12:5000',
);
class ApiException implements Exception {

View File

@@ -26,9 +26,9 @@ class AppColors {
static const Color medication = Color(0xFF00B8D9);
static const Color medicationLight = Color(0xFFEAFBFF);
static const Color medicationBorder = Color(0xFFB8F0FA);
static const Color exercise = Color(0xFF8EC5FC);
static const Color exerciseLight = Color(0xFFF5F0FF);
static const Color exerciseBorder = Color(0xFFDCCBFE);
static const Color exercise = Color(0xFF4F7DD9);
static const Color exerciseLight = Color(0xFFEFF4FF);
static const Color exerciseBorder = Color(0xFFD5E2FF);
static const Color report = Color(0xFF6366F1);
static const Color reportLight = Color(0xFFEFFBFF);
static const Color reportBorder = Color(0xFFBAE6FD);
@@ -51,7 +51,7 @@ class AppColors {
static const Color followupLight = Color(0xFFFDF2F8);
static const Color followupBorder = Color(0xFFFBCFE8);
static const Color background = Color(0xFFF8FAFF);
static const Color background = Color(0xFFF7F8FA);
static const Color backgroundSoft = Color(0xFFFFFFFF);
static const Color cardBackground = Color(0xFFFFFFFF);
static const Color cardInner = Color(0xFFF1F5F9);
@@ -70,10 +70,13 @@ class AppColors {
static const Color success = Color(0xFF16A34A);
static const Color successLight = Color(0xFFEAF8EF);
static const Color successText = Color(0xFF15803D);
static const Color error = Color(0xFFEF4444);
static const Color errorLight = Color(0xFFFEECEF);
static const Color errorText = Color(0xFFDC2626);
static const Color warning = Color(0xFFF59E0B);
static const Color warningLight = Color(0xFFFFF7E6);
static const Color warningText = Color(0xFFB45309);
static const Color infoLight = Color(0xFFEFF5FF);
static const LinearGradient primaryGradient = LinearGradient(

View File

@@ -168,4 +168,3 @@ class AppTextStyles {
height: 1.25,
);
}

View File

@@ -40,6 +40,8 @@ class AppModuleVisual {
class AppModuleVisuals {
AppModuleVisuals._();
static const healthOverviewIcon = Icons.trending_up;
static const ai = AppModuleVisual(
module: AppModule.ai,
label: 'AI',
@@ -73,7 +75,7 @@ class AppModuleVisuals {
static const exercise = AppModuleVisual(
module: AppModule.exercise,
label: '运动',
icon: LucideIcons.footprints,
icon: Icons.directions_run_outlined,
color: AppColors.exercise,
lightColor: AppColors.exerciseLight,
borderColor: AppColors.exerciseBorder,

View File

@@ -15,7 +15,9 @@ import '../pages/settings/notification_prefs_page.dart';
import '../pages/notifications/notification_center_page.dart';
import '../pages/history/conversation_history_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/profile/profile_edit_page.dart';
import '../pages/diet/diet_capture_page.dart';
import '../pages/exercise/exercise_plan_page.dart';
import '../pages/device/device_scan_page.dart';
import '../pages/device/device_management_page.dart';
import '../pages/remaining_pages.dart';
@@ -67,9 +69,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
case 'medCheckIn':
return MedicationCheckInPage(medId: params['id']);
case 'medicationEdit':
return const MedicationEditPage();
return MedicationEditPage(id: params['id']);
case 'reports':
return const ReportListPage();
return ReportListPage(openUploadOnEnter: params['openUpload'] == 'true');
case 'aiAnalysis':
final id = _requiredParam(params, 'id');
return id == null ? _missingParamPage() : AiAnalysisPage(id: id);
@@ -77,11 +79,12 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return ReportOriginalPage(
url: params['url'] ?? '',
title: params['title'] ?? '原始报告',
fileType: params['fileType'] ?? 'Image',
);
case 'exercisePlan':
return const ExercisePlanPage();
return const EnterpriseExercisePlanPage();
case 'exercisePlanDetail':
return const ExercisePlanPage();
return const EnterpriseExercisePlanPage();
case 'exerciseCreate':
return const ExercisePlanCreatePage();
case 'dietRecords':
@@ -90,6 +93,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
return const DietCapturePage();
case 'profile':
return const ProfilePage();
case 'profileEdit':
return const ProfileEditPage();
case 'doctorProfile':
return const DoctorProfileEditPage();
case 'doctorSettings':

View File

@@ -45,8 +45,7 @@ Future<DateTime?> showAppDatePicker(
final monthCtrl = FixedExtentScrollController(initialItem: m - 1);
final dayCtrl = FixedExtentScrollController(initialItem: d - 1);
int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
void clampDay() {
final maxDay = daysInMonth(y, m);
@@ -70,12 +69,22 @@ Future<DateTime?> showAppDatePicker(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
child: const Text(
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
child: const Text(
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () {
clampDay();
Navigator.pop(ctx, DateTime(y, m, d));
@@ -87,37 +96,68 @@ Future<DateTime?> showAppDatePicker(
Expanded(
child: Row(
children: [
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
y = minDate.year + i;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
m = i + 1;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
y = minDate.year + i;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [
for (var i = minDate.year; i <= maxDate.year; i++)
Center(child: Text('$i', style: _pickerItemStyle)),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
m = i + 1;
clampDay();
final maxDay = daysInMonth(y, m);
dayCtrl.jumpTo((d - 1).clamp(0, maxDay - 1).toDouble());
},
children: [
for (var i = 1; i <= 12; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [
for (var i = 1; i <= daysInMonth(y, m); i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
],
),
),
@@ -153,13 +193,24 @@ Future<TimeOfDay?> showAppTimePicker(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
child: const Text(
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
onPressed: () => Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
child: const Text(
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () =>
Navigator.pop(ctx, TimeOfDay(hour: hour, minute: minute)),
),
],
),
@@ -168,20 +219,46 @@ Future<TimeOfDay?> showAppTimePicker(
child: Row(
children: [
const Spacer(flex: 1),
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => hour = i,
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(flex: 2, child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => minute = i,
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(
flex: 2,
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => hour = i,
children: [
for (var i = 0; i <= 23; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
Expanded(
flex: 2,
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => minute = i,
children: [
for (var i = 0; i <= 59; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
const Spacer(flex: 1),
],
),
@@ -211,8 +288,7 @@ Future<DateTime?> showAppDateTimePicker(
final hourCtrl = FixedExtentScrollController(initialItem: h);
final minuteCtrl = FixedExtentScrollController(initialItem: min);
int daysInMonth(int year, int month) =>
DateTime(year, month + 1, 0).day;
int daysInMonth(int year, int month) => DateTime(year, month + 1, 0).day;
void clampDay() {
final maxDay = daysInMonth(y, m);
@@ -236,12 +312,22 @@ Future<DateTime?> showAppDateTimePicker(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
child: const Text(
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
child: const Text(
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () {
clampDay();
Navigator.pop(ctx, DateTime(y, m, d, h, min));
@@ -253,41 +339,102 @@ Future<DateTime?> showAppDateTimePicker(
Expanded(
child: Row(
children: [
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) { y = minDate.year + i; clampDay(); },
children: [for (var i = minDate.year; i <= maxDate.year; i++) Center(child: Text('$i', style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) { m = i + 1; clampDay(); },
children: [for (var i = 1; i <= 12; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [for (var i = 1; i <= daysInMonth(y, m); i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => h = i,
children: [for (var i = 0; i <= 23; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => min = i,
children: [for (var i = 0; i <= 59; i++) Center(child: Text(i.toString().padLeft(2, '0'), style: _pickerItemStyle))],
))),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: yearCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
y = minDate.year + i;
clampDay();
},
children: [
for (var i = minDate.year; i <= maxDate.year; i++)
Center(child: Text('$i', style: _pickerItemStyle)),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: monthCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) {
m = i + 1;
clampDay();
},
children: [
for (var i = 1; i <= 12; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: dayCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => d = i + 1,
children: [
for (var i = 1; i <= daysInMonth(y, m); i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: hourCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => h = i,
children: [
for (var i = 0; i <= 23; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
Expanded(
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: minuteCtrl,
itemExtent: 40,
onSelectedItemChanged: (i) => min = i,
children: [
for (var i = 0; i <= 59; i++)
Center(
child: Text(
i.toString().padLeft(2, '0'),
style: _pickerItemStyle,
),
),
],
),
),
),
],
),
),
@@ -322,28 +469,47 @@ Future<int?> showAppCountPicker(
children: [
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('取消', style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8))),
child: const Text(
'取消',
style: TextStyle(fontSize: 16, color: Color(0xFF94A3B8)),
),
onPressed: () => Navigator.pop(ctx),
),
CupertinoButton(
padding: EdgeInsets.zero,
child: const Text('确定', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
child: const Text(
'确定',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
onPressed: () => Navigator.pop(ctx, selected),
),
],
),
),
Expanded(
child: _wrapPicker(CupertinoPicker(
selectionOverlay: null,
scrollController: FixedExtentScrollController(initialItem: selected - min),
itemExtent: 40,
onSelectedItemChanged: (i) => selected = i + min,
children: [
for (var i = min; i <= max; i++)
Center(child: Text(label.isEmpty ? '$i' : '$i$label', style: _pickerItemStyle)),
],
)),
child: _wrapPicker(
CupertinoPicker(
selectionOverlay: null,
scrollController: FixedExtentScrollController(
initialItem: selected - min,
),
itemExtent: 40,
onSelectedItemChanged: (i) => selected = i + min,
children: [
for (var i = min; i <= max; i++)
Center(
child: Text(
label.isEmpty ? '$i' : '$i$label',
style: _pickerItemStyle,
),
),
],
),
),
),
],
),
@@ -362,7 +528,16 @@ class AppBackground extends StatelessWidget {
final content = safeArea ? SafeArea(child: child) : child;
// 全局浅灰白背景,已弃用紫色渐变。
return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
),
),
child: content,
);
}
}
@@ -386,10 +561,13 @@ class GradientScaffold extends StatelessWidget {
@override
Widget build(BuildContext context) => Scaffold(
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变
backgroundColor: const Color(0xFFF6F8FC),
// 二级页面用浅中性画布承托白色内容组;首页拥有自己的独立背景实现
backgroundColor: AppColors.background,
appBar: appBar,
body: body,
body: ColoredBox(
color: AppColors.background,
child: SizedBox.expand(child: body ?? const SizedBox.shrink()),
),
floatingActionButton: floatingActionButton,
drawer: drawer,
bottomNavigationBar: bottomNavigationBar,
@@ -485,22 +663,22 @@ class AppTheme {
surface: surface,
brightness: Brightness.light,
),
scaffoldBackgroundColor: Colors.transparent,
scaffoldBackgroundColor: AppColors.background,
splashColor: primary.withValues(alpha: 0.06),
highlightColor: Colors.transparent,
hoverColor: primaryLight.withValues(alpha: 0.55),
fontFamily: null,
appBarTheme: AppBarTheme(
backgroundColor: Colors.white.withValues(alpha: 0.9),
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: text,
elevation: 0,
centerTitle: true,
scrolledUnderElevation: 0,
surfaceTintColor: Colors.transparent,
titleTextStyle: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w700,
titleTextStyle: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w800,
color: text,
),
toolbarHeight: 52,

View File

@@ -34,17 +34,19 @@ class LocalDatabase {
Future<void> write(String key, String value) async {
final db = await database;
await db.insert(
'kv_store',
{'key': key, 'value': value},
conflictAlgorithm: ConflictAlgorithm.replace,
);
await db.insert('kv_store', {
'key': key,
'value': value,
}, conflictAlgorithm: ConflictAlgorithm.replace);
}
Future<String?> read(String key) async {
final db = await database;
final result =
await db.query('kv_store', where: 'key = ?', whereArgs: [key]);
final result = await db.query(
'kv_store',
where: 'key = ?',
whereArgs: [key],
);
if (result.isEmpty) return null;
return result.first['value'] as String?;
}