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:
@@ -1,13 +1,14 @@
|
||||
import 'dart:io';
|
||||
import 'dart:async';
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.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/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
@@ -28,7 +29,6 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
final _scrollCtrl = ScrollController();
|
||||
final _focusNode = FocusNode();
|
||||
final _scaffoldKey = GlobalKey<ScaffoldState>();
|
||||
double? _drawerDragStartX;
|
||||
String? _pickedImagePath;
|
||||
Timer? _notificationTimer;
|
||||
|
||||
@@ -36,19 +36,21 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
_scheduleScrollToLatest();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
_checkDueNotifications();
|
||||
_refreshTodayHealthIfCached();
|
||||
});
|
||||
_notificationTimer = Timer.periodic(
|
||||
const Duration(minutes: 2),
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
(_) => _checkDueNotifications(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeAppLifecycleState(AppLifecycleState state) {
|
||||
if (state == AppLifecycleState.resumed) {
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
_checkDueNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +78,26 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
}
|
||||
|
||||
void _refreshTodayHealthIfCached() {
|
||||
if (!ref.read(todayHealthSnapshotProvider).hasValue) return;
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
}
|
||||
|
||||
void _scheduleScrollToLatest() {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||
|
||||
// Lazy list items can refine the final extent after the first layout.
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final auth = ref.watch(authProvider);
|
||||
@@ -105,55 +127,36 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
current,
|
||||
) {
|
||||
if (current <= (previous ?? 0)) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (mounted && _scrollCtrl.hasClients) {
|
||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||
}
|
||||
});
|
||||
_scheduleScrollToLatest();
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
backgroundColor: const Color(0xFFFFFCFF),
|
||||
drawer: const HealthDrawer(),
|
||||
drawerEnableOpenDragGesture: false,
|
||||
body: AppBackground(
|
||||
safeArea: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: Stack(
|
||||
children: [
|
||||
RepaintBoundary(
|
||||
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||
),
|
||||
Positioned(
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: MediaQuery.sizeOf(context).width * 0.8,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (details) {
|
||||
_drawerDragStartX = details.globalPosition.dx;
|
||||
},
|
||||
onHorizontalDragUpdate: (details) {
|
||||
final startX = _drawerDragStartX;
|
||||
if (startX == null) return;
|
||||
if (details.globalPosition.dx - startX < 28) return;
|
||||
_drawerDragStartX = null;
|
||||
_scaffoldKey.currentState?.openDrawer();
|
||||
},
|
||||
onHorizontalDragEnd: (_) => _drawerDragStartX = null,
|
||||
onHorizontalDragCancel: () => _drawerDragStartX = null,
|
||||
),
|
||||
),
|
||||
],
|
||||
drawerEnableOpenDragGesture: true,
|
||||
drawerDragStartBehavior: DragStartBehavior.down,
|
||||
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.8,
|
||||
body: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFF0EDFF), Color(0xFFE6ECFF)],
|
||||
),
|
||||
),
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: RepaintBoundary(
|
||||
child: _HomeMessages(scrollCtrl: _scrollCtrl),
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -165,20 +168,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
: '用户';
|
||||
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.34),
|
||||
border: Border(
|
||||
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.025),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(14, 8, 14, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
@@ -189,7 +179,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
);
|
||||
},
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -261,39 +251,44 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
.read(chatProvider.notifier)
|
||||
.triggerAgent(agent, visual.label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.auraIndigo.withValues(alpha: 0.42)
|
||||
: const Color(0xFFD5DAE4),
|
||||
width: selected ? 1.4 : 1.1,
|
||||
),
|
||||
color: selected ? null : Colors.transparent,
|
||||
gradient: selected ? AppColors.actionOutlineGradient : null,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 16,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
gradient: visual.gradient,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
padding: const EdgeInsets.all(1.2),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 11.8,
|
||||
vertical: 7.8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
decoration: BoxDecoration(
|
||||
gradient: visual.gradient,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(visual.icon, size: 13, color: Colors.white),
|
||||
),
|
||||
child: Icon(visual.icon, size: 11, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
visual.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
visual.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -314,7 +309,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
|
||||
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
|
||||
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
|
||||
ActiveAgent.report => fromModule('报告分析', AppModuleVisuals.report),
|
||||
ActiveAgent.report => fromModule('报告', AppModuleVisuals.report),
|
||||
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
|
||||
_ => null,
|
||||
};
|
||||
@@ -400,74 +395,87 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
Widget _buildInputBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RoundToolButton(
|
||||
icon: LucideIcons.plus,
|
||||
onTap: () => _showAttachmentPicker(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 118),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(6, 5, 6, 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: () => _showAttachmentPicker(context),
|
||||
child: Ink(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.plus,
|
||||
size: 21,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你想说的...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 11,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 112),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(
|
||||
color: AppColors.auraIndigo,
|
||||
width: 1.2,
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你想说的...',
|
||||
filled: false,
|
||||
isDense: true,
|
||||
contentPadding: EdgeInsets.symmetric(
|
||||
horizontal: 4,
|
||||
vertical: 12,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
enabledBorder: InputBorder.none,
|
||||
focusedBorder: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
customBorder: const CircleBorder(),
|
||||
onTap: _sendMessage,
|
||||
child: Ink(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.send,
|
||||
size: 18,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.send,
|
||||
size: 19,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -517,23 +525,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('上传 PDF'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
withData: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final pdfFile = result.files.first;
|
||||
final path = pdfFile.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
// 上传 + 让 AI 看 PDF 内容
|
||||
await ref
|
||||
.read(chatProvider.notifier)
|
||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||
_textCtrl.clear();
|
||||
_dismissKeyboard();
|
||||
if (mounted) setState(() {});
|
||||
await _pickPdf();
|
||||
},
|
||||
),
|
||||
],
|
||||
@@ -563,6 +555,34 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
Future<void> _pickPdf() async {
|
||||
_dismissKeyboard();
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
withData: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final pdfFile = result.files.first;
|
||||
final path = pdfFile.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
await ref
|
||||
.read(chatProvider.notifier)
|
||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||
_textCtrl.clear();
|
||||
_dismissKeyboard();
|
||||
}
|
||||
|
||||
Future<void> _checkDueNotifications() async {
|
||||
try {
|
||||
await ref.read(inAppNotificationServiceProvider).checkDue();
|
||||
} catch (_) {
|
||||
// Network failures should not block the home page.
|
||||
} finally {
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickFoodImage(ImageSource source) async {
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
@@ -609,8 +629,8 @@ class _HeaderIconButton extends StatelessWidget {
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -647,28 +667,3 @@ class _HeaderIconButton extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundToolButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _RoundToolButton({required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
margin: const EdgeInsets.only(bottom: 2),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Icon(icon, size: 21, color: AppColors.primaryDark),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user