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:
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user