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

@@ -3,6 +3,7 @@ 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/app_module_visuals.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/chat_provider.dart';
@@ -16,22 +17,13 @@ class HealthDrawer extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final latestHealth = ref.watch(latestHealthProvider);
return DrawerShell(
widthFactor: 0.9,
showBackground: false,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _DrawerHero(
user: auth.user,
latestHealth: latestHealth,
ref: ref,
),
),
const SliverToBoxAdapter(child: _DrawerHeroConsumer()),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
@@ -43,13 +35,7 @@ class HealthDrawer extends ConsumerWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_NavigationSection(ref: ref),
const SizedBox(height: 14),
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
const SizedBox(height: 14),
const SizedBox(height: 22),
_HistorySection(ref: ref),
],
),
@@ -61,6 +47,17 @@ class HealthDrawer extends ConsumerWidget {
}
}
class _DrawerHeroConsumer extends ConsumerWidget {
const _DrawerHeroConsumer();
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(authProvider.select((state) => state.user));
final latestHealth = ref.watch(latestHealthProvider);
return _DrawerHero(user: user, latestHealth: latestHealth, ref: ref);
}
}
class _DrawerHero extends StatelessWidget {
final dynamic user;
final AsyncValue<Map<String, dynamic>> latestHealth;
@@ -413,7 +410,7 @@ class _NavigationSection extends StatelessWidget {
colors: const [Color(0xFFFBCFE8), Color(0xFFF9A8D4)],
),
_NavItem(
icon: LucideIcons.footprints,
icon: AppModuleVisuals.exercise.icon,
title: '运动',
route: 'exercisePlan',
colors: AppColors.exerciseGradient.colors,
@@ -429,7 +426,7 @@ class _NavigationSection extends StatelessWidget {
return _LightSection(
title: '常用功能',
child: GridView.builder(
padding: EdgeInsets.zero,
padding: const EdgeInsets.only(top: 3),
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
@@ -530,8 +527,13 @@ class _NavTile extends StatelessWidget {
class _LightSection extends StatelessWidget {
final String title;
final Widget child;
final Widget? trailing;
const _LightSection({required this.title, required this.child});
const _LightSection({
required this.title,
required this.child,
this.trailing,
});
@override
Widget build(BuildContext context) => Column(
@@ -551,10 +553,11 @@ class _LightSection extends StatelessWidget {
),
),
),
?trailing,
],
),
),
const SizedBox(height: 2),
const SizedBox(height: 8),
child,
],
);
@@ -711,7 +714,7 @@ class _HistorySection extends ConsumerStatefulWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
static const int _previewCount = 7;
@override
ConsumerState<_HistorySection> createState() => _HistorySectionState();
@@ -721,6 +724,9 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
String? _selectedId;
bool _selecting = false;
bool _deleting = false;
bool _expanded = false;
bool _clearing = false;
String? _loadingConversationId;
void _enterSelect(String id) {
setState(() {
@@ -750,9 +756,13 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
@override
Widget build(BuildContext context) {
final async = ref.watch(conversationHistoryProvider);
final currentConversationId = ref.watch(
chatProvider.select((state) => state.conversationId),
);
return _LightSection(
title: '对话记录',
trailing: _buildHeaderAction(context, async),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
@@ -771,7 +781,10 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
),
),
data: (list) {
data: (allItems) {
final list = allItems
.where((item) => item.id != currentConversationId)
.toList();
if (list.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 12),
@@ -781,89 +794,140 @@ class _HistorySectionState extends ConsumerState<_HistorySection> {
),
);
}
final preview = list.take(_HistorySection._previewCount).toList();
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
selecting: _selecting,
selected: _selectedId == preview[i].id,
onTap: () async {
if (_selecting) {
_toggleSelect(preview[i].id);
return;
}
final error = await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
if (!context.mounted) return;
if (error != null) {
AppToast.show(
context,
error,
type: AppToastType.error,
);
return;
}
Navigator.of(context).maybePop();
},
onLongPress: () => _enterSelect(preview[i].id),
final visible = _expanded
? list
: list.take(_HistorySection._previewCount).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < visible.length; i++)
_DrawerHistoryTile(
item: visible[i],
selecting: _selecting,
selected: _selectedId == visible[i].id,
loading: _loadingConversationId == visible[i].id,
onTap: () => _openConversation(context, visible[i].id),
onLongPress: _loadingConversationId == null && !_deleting
? () => _enterSelect(visible[i].id)
: null,
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: _selecting
? null
: () => setState(() => _expanded = !_expanded),
borderRadius: BorderRadius.circular(999),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
child: Text(
_expanded ? '收起' : '显示全部',
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
],
],
),
if (_selecting && _selectedId != null)
Positioned(
top: -46,
right: 2,
child: _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
),
),
),
],
),
],
);
},
),
);
}
Widget? _buildHeaderAction(
BuildContext context,
AsyncValue<List<ConversationListItem>> async,
) {
if (_selecting && _selectedId != null) {
return _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
);
}
final hasHistory = async.asData?.value.isNotEmpty == true;
if (!_expanded || !hasHistory) return null;
return TextButton(
onPressed: _clearing ? null : () => _confirmClearAll(context),
style: TextButton.styleFrom(
foregroundColor: AppColors.error,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
minimumSize: Size.zero,
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
),
child: Text(
_clearing ? '清空中' : '清空全部',
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
),
);
}
Future<void> _openConversation(BuildContext context, String id) async {
if (_loadingConversationId != null || _deleting || _clearing) return;
if (_selecting) {
_toggleSelect(id);
return;
}
setState(() => _loadingConversationId = id);
final error = await ref.read(chatProvider.notifier).loadConversation(id);
if (!mounted) return;
setState(() => _loadingConversationId = null);
if (error != null) {
if (context.mounted) {
AppToast.show(context, error, type: AppToastType.error);
}
return;
}
if (context.mounted) Navigator.of(context).maybePop();
}
Future<void> _confirmClearAll(BuildContext context) async {
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('清空全部对话'),
content: const Text('清空后无法恢复,确定继续吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _clearing = true);
try {
final count = await ref
.read(conversationHistoryProvider.notifier)
.clearAll();
if (!context.mounted) return;
setState(() => _expanded = false);
AppToast.show(context, '已清空 $count 条对话', type: AppToastType.success);
} catch (_) {
if (context.mounted) {
AppToast.show(context, '清空失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _clearing = false);
}
}
Future<void> _deleteSelected(BuildContext context) async {
final id = _selectedId;
if (id == null || _deleting) return;
@@ -975,16 +1039,16 @@ class _HistoryActionButton extends StatelessWidget {
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final bool selecting;
final bool selected;
final bool loading;
final VoidCallback onTap;
final VoidCallback onLongPress;
final VoidCallback? onLongPress;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.selecting,
required this.selected,
required this.loading,
required this.onTap,
required this.onLongPress,
});
@@ -1025,23 +1089,24 @@ class _DrawerHistoryTile extends StatelessWidget {
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
if (loading)
const SizedBox(
width: 16,
height: 16,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
),
],
),
),
if (!isLast)
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
],
),
),