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:
@@ -5,10 +5,27 @@ import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../services/in_app_notification_service.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import '../../widgets/common_widgets.dart';
|
||||
|
||||
Future<bool> acknowledgeBeforeNotificationOpen({
|
||||
required Future<void> Function() acknowledge,
|
||||
VoidCallback? onAcknowledged,
|
||||
required VoidCallback open,
|
||||
}) async {
|
||||
var acknowledged = true;
|
||||
try {
|
||||
await acknowledge();
|
||||
onAcknowledged?.call();
|
||||
} catch (_) {
|
||||
acknowledged = false;
|
||||
}
|
||||
open();
|
||||
return acknowledged;
|
||||
}
|
||||
|
||||
class NotificationCenterPage extends ConsumerStatefulWidget {
|
||||
const NotificationCenterPage({super.key});
|
||||
@@ -61,24 +78,40 @@ class _NotificationCenterPageState
|
||||
|
||||
Future<void> _open(InAppNotification item) async {
|
||||
if (!item.isRead) {
|
||||
await _service.acknowledge(item.id);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
final current = _history;
|
||||
if (current == null) return;
|
||||
_history = InAppNotificationHistory(
|
||||
unreadCount: (current.unreadCount - 1).clamp(0, current.unreadCount),
|
||||
items: current.items
|
||||
.map(
|
||||
(value) =>
|
||||
value.id == item.id ? value.copyWith(isRead: true) : value,
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
await acknowledgeBeforeNotificationOpen(
|
||||
acknowledge: () => _service.acknowledge(item.id),
|
||||
onAcknowledged: () {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
final current = _history;
|
||||
if (current == null) return;
|
||||
_history = InAppNotificationHistory(
|
||||
unreadCount: (current.unreadCount - 1).clamp(
|
||||
0,
|
||||
current.unreadCount,
|
||||
),
|
||||
items: current.items
|
||||
.map(
|
||||
(value) => value.id == item.id
|
||||
? value.copyWith(isRead: true)
|
||||
: value,
|
||||
)
|
||||
.toList(),
|
||||
);
|
||||
});
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
},
|
||||
open: () {
|
||||
if (mounted) _openTarget(item);
|
||||
},
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
_openTarget(item);
|
||||
}
|
||||
|
||||
void _openTarget(InAppNotification item) {
|
||||
if (!mounted || item.actionType == null) return;
|
||||
switch (item.actionType) {
|
||||
case 'medication':
|
||||
@@ -149,11 +182,11 @@ class _NotificationCenterPageState
|
||||
final unread = _history?.unreadCount ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0.5,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
@@ -194,10 +227,15 @@ class _NotificationCenterPageState
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: AppColors.primary,
|
||||
child: _buildBody(items, unread),
|
||||
body: ColoredBox(
|
||||
color: AppColors.background,
|
||||
child: SizedBox.expand(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: AppColors.primary,
|
||||
child: _buildBody(items, unread),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -319,13 +357,6 @@ class _NotificationGroup extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.035),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
@@ -339,27 +370,38 @@ class _UnreadHint extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
margin: const EdgeInsets.fromLTRB(0, 2, 0, 10),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.notificationLight,
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.notificationBorder),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.bellRing,
|
||||
size: 18,
|
||||
color: AppColors.notification,
|
||||
size: 17,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
'你有 $unreadCount 条未读消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.notification,
|
||||
const SizedBox(width: 8),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
children: [
|
||||
const TextSpan(text: '还有 '),
|
||||
TextSpan(
|
||||
text: '$unreadCount',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.errorText,
|
||||
),
|
||||
),
|
||||
const TextSpan(text: ' 条未读消息'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -465,27 +507,24 @@ class _NotificationRow extends StatelessWidget {
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(minHeight: 94),
|
||||
constraints: const BoxConstraints(minHeight: 82),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 10, 10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
gradient: visual.gradient,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
color: visual.borderColor.withValues(alpha: 0.85),
|
||||
),
|
||||
color: visual.lightColor,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(visual.icon, size: 24, color: Colors.white),
|
||||
child: Icon(visual.icon, size: 21, color: visual.color),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -493,10 +532,26 @@ class _NotificationRow extends StatelessWidget {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.w700
|
||||
: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 3,
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
@@ -505,45 +560,22 @@ class _NotificationRow extends StatelessWidget {
|
||||
child: Text(
|
||||
visual.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: visual.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.w700
|
||||
: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.message,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.25,
|
||||
fontSize: 13,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -551,20 +583,36 @@ class _NotificationRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 12,
|
||||
child: Center(
|
||||
child: item.isRead
|
||||
? const SizedBox.shrink()
|
||||
: Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
width: 68,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
if (item.isRead)
|
||||
const SizedBox(height: 8)
|
||||
else
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.right,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -572,7 +620,7 @@ class _NotificationRow extends StatelessWidget {
|
||||
),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 75),
|
||||
padding: EdgeInsets.only(left: 65),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
@@ -597,8 +645,8 @@ class _NotificationRow extends StatelessWidget {
|
||||
local.day == now.day) {
|
||||
return time;
|
||||
}
|
||||
if (local.year == now.year) return '${local.month}/${local.day} $time';
|
||||
return '${local.year}/${local.month}/${local.day} $time';
|
||||
if (local.year == now.year) return '${local.month}-${local.day} $time';
|
||||
return '${local.year}-${local.month}-${local.day} $time';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -606,26 +654,15 @@ class _NotificationVisual {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color lightColor;
|
||||
final Color borderColor;
|
||||
final LinearGradient gradient;
|
||||
final String label;
|
||||
|
||||
const _NotificationVisual(
|
||||
this.icon,
|
||||
this.color,
|
||||
this.lightColor,
|
||||
this.borderColor,
|
||||
this.gradient,
|
||||
this.label,
|
||||
);
|
||||
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
||||
|
||||
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
|
||||
return _NotificationVisual(
|
||||
visual.icon,
|
||||
visual.color,
|
||||
visual.lightColor,
|
||||
visual.borderColor,
|
||||
visual.gradient,
|
||||
visual.label,
|
||||
);
|
||||
}
|
||||
@@ -639,14 +676,8 @@ class _NotificationVisual {
|
||||
return item.severity == 'critical'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
Color(0xFFFECACA),
|
||||
LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)],
|
||||
),
|
||||
AppColors.errorText,
|
||||
AppColors.errorLight,
|
||||
'紧急',
|
||||
)
|
||||
: _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||
@@ -654,14 +685,8 @@ class _NotificationVisual {
|
||||
return item.severity == 'error'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
Color(0xFFFECACA),
|
||||
LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)],
|
||||
),
|
||||
AppColors.errorText,
|
||||
AppColors.errorLight,
|
||||
'报告',
|
||||
)
|
||||
: _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||
@@ -693,62 +718,23 @@ class _MessageState extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) => ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
|
||||
padding: const EdgeInsets.fromLTRB(28, 100, 28, 32),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primaryLight,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(icon, size: 32, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (buttonText != null) ...[
|
||||
const SizedBox(height: 22),
|
||||
FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28,
|
||||
vertical: 12,
|
||||
),
|
||||
AppEmptyState(
|
||||
icon: icon,
|
||||
iconColor: onPressed == null ? AppColors.notification : AppColors.error,
|
||||
title: title,
|
||||
subtitle: message,
|
||||
padding: const EdgeInsets.all(24),
|
||||
action: buttonText == null
|
||||
? null
|
||||
: SizedBox(
|
||||
width: 140,
|
||||
child: AppGradientOutlineButton(
|
||||
label: buttonText!,
|
||||
onPressed: onPressed,
|
||||
),
|
||||
child: Text(buttonText!),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user