From 0d4fd88ce7da8cf45d3b8909b13807d15a2249e8 Mon Sep 17 00:00:00 2001 From: MingNian <1281442923@qq.com> Date: Sun, 19 Jul 2026 19:11:30 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=B8=89=E7=AB=AF=E6=8A=BD=E5=B1=89?= =?UTF-8?q?=E9=87=8D=E6=9E=84=20+=20=E9=85=8D=E8=89=B2=E7=B3=BB=E7=BB=9F?= =?UTF-8?q?=E6=9B=B4=E6=96=B0=20+=20=E5=90=8E=E5=8F=B0=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E9=A1=B5=E7=B2=BE=E8=B0=83=20+=20=E9=94=AE=E7=9B=98=E6=8A=AC?= =?UTF-8?q?=E8=B5=B7=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 抽屉重构 - admin_drawer / doctor_drawer / health_drawer 三端抽屉全部重做 - drawer_shell 增强 ## 配色系统 - app_colors / app_module_visuals / app_theme 更新 - app.dart 启动流程调整 ## 后台管理页 - admin_doctors_page 大幅重构(+251) - admin_home / doctor_dashboard / doctor_reports / doctor_home / doctor_followups / doctor_settings 精调 ## 患者端 - home_page / chat_messages_view / medication_list / notification_center / remaining_pages / exercise_plan / device / diet / consultation 微调 ## 新增 - keyboard_lift.dart: 键盘抬起处理组件 - AGENTS.md: agent 指引文档 ## 其他 - api_client IP 适配 - app_future_view 增强 - data_providers 调整 - secondary_page_visuals_test 更新 --- AGENTS.md | 57 ++++ health_app/lib/app.dart | 42 ++- health_app/lib/core/api_client.dart | 2 +- health_app/lib/core/app_colors.dart | 10 +- health_app/lib/core/app_module_visuals.dart | 2 +- health_app/lib/core/app_theme.dart | 3 + .../pages/admin/admin_add_doctor_page.dart | 1 + .../lib/pages/admin/admin_doctors_page.dart | 253 +++++++++--------- .../lib/pages/admin/admin_home_page.dart | 57 ++-- .../consultation/consultation_pages.dart | 4 +- .../pages/device/device_management_page.dart | 21 +- .../lib/pages/device/device_scan_page.dart | 9 +- .../lib/pages/diet/diet_capture_page.dart | 1 + .../pages/doctor/doctor_dashboard_page.dart | 39 +-- .../doctor/doctor_followup_edit_page.dart | 1 + .../pages/doctor/doctor_followups_page.dart | 8 +- .../lib/pages/doctor/doctor_home_page.dart | 52 +++- .../doctor/doctor_report_detail_page.dart | 1 + .../lib/pages/doctor/doctor_reports_page.dart | 132 ++++----- .../pages/doctor/doctor_settings_page.dart | 8 + .../pages/exercise/exercise_plan_page.dart | 26 +- health_app/lib/pages/home/home_page.dart | 18 +- .../home/widgets/chat_messages_view.dart | 19 +- .../medication/medication_list_page.dart | 23 +- .../notification_center_page.dart | 71 +++-- health_app/lib/pages/remaining_pages.dart | 193 +++++++------ health_app/lib/providers/data_providers.dart | 29 +- health_app/lib/widgets/admin_drawer.dart | 189 +++++++------ health_app/lib/widgets/app_future_view.dart | 33 +++ health_app/lib/widgets/ble_sync_dialog.dart | 2 +- health_app/lib/widgets/doctor_drawer.dart | 178 ++++++------ health_app/lib/widgets/drawer_shell.dart | 39 +++ health_app/lib/widgets/health_drawer.dart | 117 +++++--- health_app/lib/widgets/keyboard_lift.dart | 17 ++ .../test/secondary_page_visuals_test.dart | 9 +- 35 files changed, 1022 insertions(+), 644 deletions(-) create mode 100644 AGENTS.md create mode 100644 health_app/lib/widgets/keyboard_lift.dart diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..2e75247 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,57 @@ +# 项目协作准则 + +本文件适用于整个 `health_project` 仓库。处理任务时按改动风险分级,验证强度与风险匹配,避免小改动套用重型流程。 + +## 默认规则 + +- 默认不执行 `git add`、`git commit`、`git push`、合并、回滚或创建分支;只有用户明确要求时才进行。 +- 保留工作区中用户已有的未提交改动,不覆盖、不清理、不顺带重构。 +- 数据库、上传文件、密钥、生产配置和用户数据不视为普通文件;任何写入、迁移、删除或恢复操作必须先获得用户明确确认。 +- 只修改用户指定的端和功能范围。发现相邻问题可以说明,但不要擅自扩大修改范围。 + +## 小改动:快速处理 + +适用于颜色、字号、间距、圆角、文案、图标、单个组件简单布局等不涉及状态、接口和数据的调整。 + +1. 确认具体文件和修改范围。 +2. 直接完成修改。 +3. 执行格式化。 +4. 进行一次快速编译或静态检查,二者按实际需要选择。 +5. 简要说明改动结果。 + +不运行完整测试套件,不额外编写计划文档,不提交 Git。 + +## 中等改动:针对性验证 + +适用于页面交互、Provider、路由、表单逻辑、接口调用、跨端数据展示、启动脚本和配置修改。 + +1. 检查相关代码和现有改动。 +2. 修改前简要说明方案。 +3. 完成代码修改和格式化。 +4. 运行静态检查或编译。 +5. 只运行与本次改动直接相关的测试。 +6. 必要时启动对应页面或接口验证一次。 + +默认不提交 Git。 + +## 大改动:完整验证 + +适用于完整新功能、多页面联动、登录、聊天、蓝牙、AI 核心流程、后端结构调整和大范围重构。 + +1. 阅读相关代码、文档和交接资料。 +2. 与用户确认范围、交互和成功标准。 +3. 分步骤实施,并在关键阶段做针对性检查。 +4. 完成后运行 Flutter 和后端相关完整测试。 +5. 汇总改动、验证结果和遗留问题。 + +## 高风险改动:确认后执行 + +适用于数据库迁移或数据修复、大量删除、用户数据、上传文件、签名、生产配置、部署,以及 Git 提交、推送、合并和回滚。 + +1. 先进行只读检查,确认精确目标和影响。 +2. 向用户说明风险、影响范围和恢复方式。 +3. 必要时先制作可验证的备份。 +4. 获得用户明确确认后再执行。 +5. 执行后进行完整验证并报告结果。 + +用户在具体任务中的直接指令始终优先于本文件。 diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index 54b49ec..571b683 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -74,11 +74,28 @@ class _BootGateState extends ConsumerState<_BootGate> { @override void initState() { super.initState(); + _startTimeout(); + } + + void _startTimeout() { + _timer?.cancel(); _timer = Timer(const Duration(seconds: 8), () { if (mounted) setState(() => _timedOut = true); }); } + void _handleReadyChanged(bool? previous, bool ready) { + if (ready) { + _timer?.cancel(); + if (_timedOut) setState(() => _timedOut = false); + return; + } + if (previous != false) { + if (_timedOut) setState(() => _timedOut = false); + _startTimeout(); + } + } + @override void dispose() { _timer?.cancel(); @@ -87,6 +104,7 @@ class _BootGateState extends ConsumerState<_BootGate> { @override Widget build(BuildContext context) { + ref.listen(appReadyProvider, _handleReadyChanged); final ready = ref.watch(appReadyProvider) || _timedOut; return Stack( children: [ @@ -135,9 +153,21 @@ class _RootNavigator extends ConsumerWidget { } return PopScope( - canPop: stack.length <= 1, + canPop: false, onPopInvokedWithResult: (didPop, result) { - if (!didPop) popRoute(ref); + if (didPop) return; + if (stack.length > 1) { + popRoute(ref); + return; + } + final homeRoute = switch (authState.user?.role) { + 'Admin' => 'adminHome', + 'Doctor' => 'doctorHome', + _ => 'home', + }; + if (current.name != homeRoute && authState.isLoggedIn) { + goRoute(ref, homeRoute); + } }, child: Navigator( pages: [ @@ -148,8 +178,12 @@ class _RootNavigator extends ConsumerWidget { child: buildPage(stack[index], ref), ), ], - onDidRemovePage: (_) { - if (ref.read(routeStackProvider).length > 1) popRoute(ref); + onDidRemovePage: (page) { + final routes = ref.read(routeStackProvider); + if (routes.length <= 1) return; + final lastIndex = routes.length - 1; + final currentPageKey = ValueKey(_pageKey(routes.last, lastIndex)); + if (page.key == currentPageKey) popRoute(ref); }, ), ); diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 9dd9a11..c931257 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment( 'API_BASE_URL', defaultValue: kReleaseMode ? 'https://erpapi.datalumina.cn/xiaomai' - : 'http://10.4.159.130:5000', + : 'http://10.4.165.54:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index 5aed240..caa6f09 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -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(0xFF566FD4); - static const Color exerciseLight = Color(0xFFF1F3FD); - static const Color exerciseBorder = Color(0xFFD7DCF6); + static const Color exercise = Color(0xFF7C3AED); + static const Color exerciseLight = Color(0xFFF3E8FF); + static const Color exerciseBorder = Color(0xFFDDD6FE); static const Color report = Color(0xFF6366F1); static const Color reportLight = Color(0xFFEFFBFF); static const Color reportBorder = Color(0xFFBAE6FD); @@ -168,7 +168,7 @@ class AppColors { static const LinearGradient healthGradient = LinearGradient( begin: Alignment.centerLeft, end: Alignment.centerRight, - colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)], + colors: [Color(0xFF43E97B), Color(0xFF38F9D7)], ); static const LinearGradient medicationGradient = LinearGradient( @@ -180,7 +180,7 @@ class AppColors { static const LinearGradient exerciseGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)], + colors: [Color(0xFFC4B5FD), Color(0xFF7C3AED)], ); static const LinearGradient reportGradient = LinearGradient( diff --git a/health_app/lib/core/app_module_visuals.dart b/health_app/lib/core/app_module_visuals.dart index b3e533e..a3b641f 100644 --- a/health_app/lib/core/app_module_visuals.dart +++ b/health_app/lib/core/app_module_visuals.dart @@ -95,7 +95,7 @@ class AppModuleVisuals { static const diet = AppModuleVisual( module: AppModule.diet, label: '饮食', - icon: LucideIcons.utensils, + icon: LucideIcons.salad, color: AppColors.diet, lightColor: AppColors.dietLight, borderColor: AppColors.dietBorder, diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index acd3302..739f70e 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -548,6 +548,7 @@ class GradientScaffold extends StatelessWidget { final Widget? drawer; final Widget? bottomNavigationBar; final bool extendBody; + final bool resizeToAvoidBottomInset; const GradientScaffold({ super.key, @@ -557,6 +558,7 @@ class GradientScaffold extends StatelessWidget { this.drawer, this.bottomNavigationBar, this.extendBody = false, + this.resizeToAvoidBottomInset = true, }); @override @@ -572,6 +574,7 @@ class GradientScaffold extends StatelessWidget { drawer: drawer, bottomNavigationBar: bottomNavigationBar, extendBody: extendBody, + resizeToAvoidBottomInset: resizeToAvoidBottomInset, ); } diff --git a/health_app/lib/pages/admin/admin_add_doctor_page.dart b/health_app/lib/pages/admin/admin_add_doctor_page.dart index 7858ae3..180ae44 100644 --- a/health_app/lib/pages/admin/admin_add_doctor_page.dart +++ b/health_app/lib/pages/admin/admin_add_doctor_page.dart @@ -121,6 +121,7 @@ class _AdminAddDoctorPageState extends ConsumerState { ), body: BackofficeSurface( child: SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/health_app/lib/pages/admin/admin_doctors_page.dart b/health_app/lib/pages/admin/admin_doctors_page.dart index 32d9c2a..74c9175 100644 --- a/health_app/lib/pages/admin/admin_doctors_page.dart +++ b/health_app/lib/pages/admin/admin_doctors_page.dart @@ -97,141 +97,144 @@ class _AdminDoctorsPageState extends ConsumerState { ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) { if (previous != null && previous != next) _load(); }); - return Scaffold( - backgroundColor: AppColors.pageGrey, - floatingActionButton: FloatingActionButton( - backgroundColor: AppColors.primary, - onPressed: () => pushRoute(ref, 'adminAddDoctor'), - child: const Icon(Icons.add, color: Colors.white), - ), - body: _loading - ? const BackofficeLoadingState(message: '正在加载医生') - : _error != null - ? BackofficeErrorState(message: _error!, onRetry: _load) - : _doctors.isEmpty - ? const BackofficeEmptyState( - icon: Icons.medical_services_outlined, - title: '暂无医生', - description: '点击右下角按钮添加第一位医生', - ) - : ListView.builder( - padding: const EdgeInsets.all(16), - itemCount: _doctors.length, - itemBuilder: (_, i) { - final d = _doctors[i]; - final active = d['isActive'] != false; - return BackofficeSectionCard( - margin: const EdgeInsets.only(bottom: 10), - padding: const EdgeInsets.all(14), - child: Row( - children: [ - CircleAvatar( - backgroundColor: active - ? AppColors.successLight - : AppColors.background, - child: Icon( - Icons.local_hospital, - size: 20, - color: active - ? AppColors.success - : AppColors.textHint, - ), + final body = _loading + ? const BackofficeLoadingState(message: '正在加载医生') + : _error != null + ? BackofficeErrorState(message: _error!, onRetry: _load) + : _doctors.isEmpty + ? const BackofficeEmptyState( + icon: Icons.medical_services_outlined, + title: '暂无医生', + description: '点击右下角按钮添加第一位医生', + ) + : ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: _doctors.length, + itemBuilder: (_, i) { + final d = _doctors[i]; + final active = d['isActive'] != false; + return BackofficeSectionCard( + margin: const EdgeInsets.only(bottom: 10), + padding: const EdgeInsets.all(14), + child: Row( + children: [ + CircleAvatar( + backgroundColor: active + ? AppColors.successLight + : AppColors.background, + child: Icon( + Icons.local_hospital, + size: 20, + color: active ? AppColors.success : AppColors.textHint, ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - d['name'] ?? '', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - ), - ), - const SizedBox(width: 8), - if (!active) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 6, - vertical: 2, - ), - decoration: BoxDecoration( - color: AppColors.errorLight, - borderRadius: BorderRadius.circular(4), - ), - child: const Text( - '已停用', - style: TextStyle( - fontSize: 10, - color: AppColors.error, - ), - ), - ), - ], - ), - const SizedBox(height: 4), - Text( - '${d['title'] ?? ''} · ${d['department'] ?? ''}', - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - if (d['professionalDirection'] != null) + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ Text( - d['professionalDirection']!, + d['name'] ?? '', style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, + fontSize: 16, + fontWeight: FontWeight.w600, ), ), - ], - ), - ), - PopupMenuButton( - onSelected: (v) { - if (v == 'edit') { - pushRoute( - ref, - 'adminAddDoctor', - params: { - 'id': d['id']?.toString() ?? '', - 'phone': d['phone']?.toString() ?? '', - 'name': d['name']?.toString() ?? '', - 'title': d['title']?.toString() ?? '', - 'department': d['department']?.toString() ?? '', - 'direction': - d['professionalDirection']?.toString() ?? - '', - }, - ); - } - if (v == 'toggle') _toggleActive(d['id']); - if (v == 'delete') _delete(d['id']); - }, - itemBuilder: (_) => [ - const PopupMenuItem(value: 'edit', child: Text('编辑')), - PopupMenuItem( - value: 'toggle', - child: Text(active ? '停用' : '启用'), + const SizedBox(width: 8), + if (!active) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 6, + vertical: 2, + ), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: BorderRadius.circular(4), + ), + child: const Text( + '已停用', + style: TextStyle( + fontSize: 10, + color: AppColors.error, + ), + ), + ), + ], ), - const PopupMenuItem( - value: 'delete', - child: Text( - '删除', - style: TextStyle(color: AppColors.error), + const SizedBox(height: 4), + Text( + '${d['title'] ?? ''} · ${d['department'] ?? ''}', + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, ), ), + if (d['professionalDirection'] != null) + Text( + d['professionalDirection']!, + style: const TextStyle( + fontSize: 12, + color: AppColors.textHint, + ), + ), ], ), - ], - ), - ); - }, - ), + ), + PopupMenuButton( + onSelected: (v) { + if (v == 'edit') { + pushRoute( + ref, + 'adminAddDoctor', + params: { + 'id': d['id']?.toString() ?? '', + 'phone': d['phone']?.toString() ?? '', + 'name': d['name']?.toString() ?? '', + 'title': d['title']?.toString() ?? '', + 'department': d['department']?.toString() ?? '', + 'direction': + d['professionalDirection']?.toString() ?? '', + }, + ); + } + if (v == 'toggle') _toggleActive(d['id']); + if (v == 'delete') _delete(d['id']); + }, + itemBuilder: (_) => [ + const PopupMenuItem(value: 'edit', child: Text('编辑')), + PopupMenuItem( + value: 'toggle', + child: Text(active ? '停用' : '启用'), + ), + const PopupMenuItem( + value: 'delete', + child: Text( + '删除', + style: TextStyle(color: AppColors.error), + ), + ), + ], + ), + ], + ), + ); + }, + ); + return Stack( + children: [ + Positioned.fill(child: body), + Positioned( + right: 16, + bottom: 16, + child: FloatingActionButton( + backgroundColor: AppColors.primary, + onPressed: () => pushRoute(ref, 'adminAddDoctor'), + child: const Icon(Icons.add, color: Colors.white), + ), + ), + ], ); } } diff --git a/health_app/lib/pages/admin/admin_home_page.dart b/health_app/lib/pages/admin/admin_home_page.dart index 80c1ef5..266821e 100644 --- a/health_app/lib/pages/admin/admin_home_page.dart +++ b/health_app/lib/pages/admin/admin_home_page.dart @@ -16,31 +16,48 @@ class AdminPageNotifier extends Notifier { void set(String page) => state = page; } -class AdminHomePage extends ConsumerWidget { +class AdminHomePage extends ConsumerStatefulWidget { const AdminHomePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final page = ref.watch(adminPageProvider); + ConsumerState createState() => _AdminHomePageState(); +} - return Scaffold( - backgroundColor: AppColors.pageGrey, - appBar: AppBar( - backgroundColor: Colors.transparent, - elevation: 0, - title: Text( - page == 'patients' ? '患者管理' : '医生管理', - style: TextStyle(color: AppColors.textPrimary), +class _AdminHomePageState extends ConsumerState { + final List _pages = [const AdminDoctorsPage(), null]; + + @override + Widget build(BuildContext context) { + final page = ref.watch(adminPageProvider); + final pageIndex = page == 'patients' ? 1 : 0; + _pages[pageIndex] ??= const AdminPatientsPage(); + + return PopScope( + canPop: page == 'doctors', + onPopInvokedWithResult: (didPop, _) { + if (!didPop) ref.read(adminPageProvider.notifier).set('doctors'); + }, + child: Scaffold( + backgroundColor: AppColors.pageGrey, + drawerEnableOpenDragGesture: true, + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + title: Text( + page == 'patients' ? '患者管理' : '医生管理', + style: TextStyle(color: AppColors.textPrimary), + ), + iconTheme: const IconThemeData(color: AppColors.textPrimary), + ), + drawer: const AdminDrawer(), + body: BackofficeSurface( + child: IndexedStack( + index: pageIndex, + children: _pages + .map((page) => page ?? const SizedBox.shrink()) + .toList(growable: false), + ), ), - iconTheme: const IconThemeData(color: AppColors.textPrimary), - ), - drawer: const AdminDrawer(), - body: BackofficeSurface( - child: switch (page) { - 'doctors' => const AdminDoctorsPage(), - 'patients' => const AdminPatientsPage(), - _ => const AdminDoctorsPage(), - }, ), ); } diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index 6ebbd8c..e93005a 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_pages.dart @@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../providers/consultation_provider.dart'; +import '../../widgets/keyboard_lift.dart'; /// 问诊对话页 — 完整的 AI 分身聊天界面 class DoctorChatPage extends ConsumerStatefulWidget { @@ -62,6 +63,7 @@ class _DoctorChatPageState extends ConsumerState { final canSend = state.status == 'AiTalking' && !state.isSending; return GradientScaffold( + resizeToAvoidBottomInset: false, appBar: AppBar( backgroundColor: Colors.transparent, title: Column( @@ -113,7 +115,7 @@ class _DoctorChatPageState extends ConsumerState { : Column( children: [ Expanded(child: _buildMessageList(state)), - _buildInputBar(canSend), + KeyboardLift(child: _buildInputBar(canSend)), ], ), ); diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index 2ce9f04..e72471c 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -425,9 +425,9 @@ class _DeviceManagementPageState extends ConsumerState onPressed: () => pushRoute(ref, 'deviceScan'), style: IconButton.styleFrom( backgroundColor: Colors.white, - foregroundColor: AppColors.primaryDark, + foregroundColor: AppColors.device, fixedSize: const Size(40, 40), - side: const BorderSide(color: AppColors.primaryLight), + side: const BorderSide(color: AppColors.deviceBorder), shape: const CircleBorder(), ), icon: const Icon(Icons.add_rounded), @@ -604,10 +604,10 @@ class _DevicesHeader extends StatelessWidget { width: 50, height: 50, decoration: BoxDecoration( - color: _deviceVisual.lightColor, + color: AppColors.device, borderRadius: AppRadius.smBorder, ), - child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27), + child: Icon(_deviceVisual.icon, color: Colors.white, size: 27), ), const SizedBox(width: 14), Expanded( @@ -671,14 +671,10 @@ class _DeviceSection extends StatelessWidget { width: 34, height: 34, decoration: BoxDecoration( - color: _deviceVisual.lightColor, + color: AppColors.device, borderRadius: AppRadius.smBorder, ), - child: Icon( - type.icon, - size: 19, - color: _deviceVisual.color, - ), + child: Icon(type.icon, size: 19, color: Colors.white), ), const SizedBox(width: 10), Text( @@ -733,7 +729,6 @@ class _BoundDeviceTile extends StatelessWidget { @override Widget build(BuildContext context) { - final accent = connected ? AppColors.successText : AppColors.textHint; final availability = deviceSyncAvailabilityLabel(device.type); return AnimatedContainer( duration: const Duration(milliseconds: 180), @@ -750,10 +745,10 @@ class _BoundDeviceTile extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - color: connected ? AppColors.successLight : AppColors.deviceLight, + color: AppColors.device, borderRadius: AppRadius.smBorder, ), - child: Icon(device.type.icon, color: accent, size: 22), + child: Icon(device.type.icon, color: Colors.white, size: 22), ), const SizedBox(width: 12), Expanded( diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index 18dab32..7b1a684 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -445,12 +445,12 @@ class _DeviceResultTile extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - color: AppColors.deviceLight, + color: AppColors.device, borderRadius: AppRadius.smBorder, ), child: Icon( type?.icon ?? Icons.bluetooth_rounded, - color: AppColors.device, + color: Colors.white, size: 25, ), ), @@ -565,14 +565,13 @@ class _ScanPulse extends StatelessWidget { width: 64, height: 64, decoration: BoxDecoration( - color: Colors.white, + color: AppColors.device, borderRadius: AppRadius.xlBorder, - border: Border.all(color: AppColors.borderLight), ), child: const Icon( Icons.bluetooth_searching_rounded, size: 32, - color: AppColors.device, + color: Colors.white, ), ), ], diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 0900ea4..4d51177 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -378,6 +378,7 @@ class _DietCapturePageState extends ConsumerState { final state = ref.watch(dietProvider); return SingleChildScrollView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, diff --git a/health_app/lib/pages/doctor/doctor_dashboard_page.dart b/health_app/lib/pages/doctor/doctor_dashboard_page.dart index e33906f..08bdb34 100644 --- a/health_app/lib/pages/doctor/doctor_dashboard_page.dart +++ b/health_app/lib/pages/doctor/doctor_dashboard_page.dart @@ -229,25 +229,30 @@ class _StatCard extends StatelessWidget { child: Icon(icon, color: color, size: 20), ), const SizedBox(width: 12), - Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - value, - style: const TextStyle( - fontSize: 22, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 22, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), ), - ), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, + Text( + label, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 12, + color: AppColors.textHint, + ), ), - ), - ], + ], + ), ), ], ), diff --git a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart index d29fe20..c9a4faf 100644 --- a/health_app/lib/pages/doctor/doctor_followup_edit_page.dart +++ b/health_app/lib/pages/doctor/doctor_followup_edit_page.dart @@ -78,6 +78,7 @@ class _DoctorFollowUpEditPageState ), body: BackofficeSurface( child: ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(16), children: [ const Text( diff --git a/health_app/lib/pages/doctor/doctor_followups_page.dart b/health_app/lib/pages/doctor/doctor_followups_page.dart index 8d4fa43..b7d3306 100644 --- a/health_app/lib/pages/doctor/doctor_followups_page.dart +++ b/health_app/lib/pages/doctor/doctor_followups_page.dart @@ -62,22 +62,22 @@ class _DoctorFollowupsPageState extends ConsumerState { children: [ Padding( padding: const EdgeInsets.all(16), - child: Row( + child: Wrap( + spacing: 8, + runSpacing: 8, + crossAxisAlignment: WrapCrossAlignment.center, children: [ _Filt('全部', _filter == '', () => setState(() => _filter = '')), - const SizedBox(width: 8), _Filt( '待完成', _filter == 'Upcoming', () => setState(() => _filter = 'Upcoming'), ), - const SizedBox(width: 8), _Filt( '已完成', _filter == 'Completed', () => setState(() => _filter = 'Completed'), ), - const Spacer(), IconButton( icon: const Icon(Icons.add, color: AppColors.primary), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit'), diff --git a/health_app/lib/pages/doctor/doctor_home_page.dart b/health_app/lib/pages/doctor/doctor_home_page.dart index 7113e76..6efc9b9 100644 --- a/health_app/lib/pages/doctor/doctor_home_page.dart +++ b/health_app/lib/pages/doctor/doctor_home_page.dart @@ -9,12 +9,41 @@ import 'doctor_consultations_page.dart'; import 'doctor_reports_page.dart'; import 'doctor_followups_page.dart'; -class DoctorHomePage extends ConsumerWidget { +class DoctorHomePage extends ConsumerStatefulWidget { const DoctorHomePage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { + ConsumerState createState() => _DoctorHomePageState(); +} + +class _DoctorHomePageState extends ConsumerState { + final List _pages = [ + const DoctorDashboardPage(), + null, + null, + null, + null, + ]; + + Widget _createPage(int index) => switch (index) { + 1 => const DoctorPatientsPage(), + 2 => const DoctorConsultationsPage(), + 3 => const DoctorReportsPage(), + 4 => const DoctorFollowupsPage(), + _ => const DoctorDashboardPage(), + }; + + @override + Widget build(BuildContext context) { final page = ref.watch(doctorPageProvider); + final pageIndex = switch (page) { + 'patients' => 1, + 'consultations' => 2, + 'reports' => 3, + 'followups' => 4, + _ => 0, + }; + _pages[pageIndex] ??= _createPage(pageIndex); return PopScope( canPop: page == 'dashboard', @@ -39,20 +68,19 @@ class DoctorHomePage extends ConsumerWidget { ), ), drawer: const DoctorDrawer(), - body: BackofficeSurface(child: _bodyFor(page)), + drawerEnableOpenDragGesture: true, + body: BackofficeSurface( + child: IndexedStack( + index: pageIndex, + children: _pages + .map((page) => page ?? const SizedBox.shrink()) + .toList(growable: false), + ), + ), ), ); } - Widget _bodyFor(String page) => switch (page) { - 'dashboard' => const DoctorDashboardPage(), - 'patients' => const DoctorPatientsPage(), - 'consultations' => const DoctorConsultationsPage(), - 'reports' => const DoctorReportsPage(), - 'followups' => const DoctorFollowupsPage(), - _ => const DoctorDashboardPage(), - }; - String _titleFor(String page) => switch (page) { 'dashboard' => '工作台', 'patients' => '患者管理', diff --git a/health_app/lib/pages/doctor/doctor_report_detail_page.dart b/health_app/lib/pages/doctor/doctor_report_detail_page.dart index 5d439d8..57e3fc4 100644 --- a/health_app/lib/pages/doctor/doctor_report_detail_page.dart +++ b/health_app/lib/pages/doctor/doctor_report_detail_page.dart @@ -132,6 +132,7 @@ class _DoctorReportDetailPageState final fileUrl = data['fileUrl'] as String?; return ListView( + keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, padding: const EdgeInsets.all(16), children: [ // 患者+分类信息 diff --git a/health_app/lib/pages/doctor/doctor_reports_page.dart b/health_app/lib/pages/doctor/doctor_reports_page.dart index c8442ac..937fc70 100644 --- a/health_app/lib/pages/doctor/doctor_reports_page.dart +++ b/health_app/lib/pages/doctor/doctor_reports_page.dart @@ -6,20 +6,13 @@ import '../../providers/auth_provider.dart'; import '../../utils/backoffice_formatters.dart'; import '../../widgets/backoffice_ui.dart'; -final _reportsProvider = - FutureProvider.family>, String>(( - ref, - status, - ) async { - final api = ref.read(apiClientProvider); - final params = {}; - if (status.isNotEmpty && status != 'All') params['status'] = status; - final res = await api.get( - '/api/doctor/reports', - queryParameters: params.isNotEmpty ? params : null, - ); - return (res.data['data'] as List?)?.cast>() ?? []; - }); +final _reportsProvider = FutureProvider>>(( + ref, +) async { + final api = ref.read(apiClientProvider); + final res = await api.get('/api/doctor/reports'); + return (res.data['data'] as List?)?.cast>() ?? []; +}); class DoctorReportsPage extends ConsumerStatefulWidget { const DoctorReportsPage({super.key}); @@ -32,12 +25,14 @@ class _DoctorReportsPageState extends ConsumerState { @override Widget build(BuildContext context) { - final reports = ref.watch(_reportsProvider(_filter)); + final reports = ref.watch(_reportsProvider); return Column( children: [ Padding( padding: const EdgeInsets.all(16), - child: Row( + child: Wrap( + spacing: 8, + runSpacing: 8, children: [ _FilterChip( '全部', @@ -45,14 +40,12 @@ class _DoctorReportsPageState extends ConsumerState { _filter, () => setState(() => _filter = ''), ), - const SizedBox(width: 8), _FilterChip( '待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor'), ), - const SizedBox(width: 8), _FilterChip( '已审核', 'DoctorReviewed', @@ -66,57 +59,66 @@ class _DoctorReportsPageState extends ConsumerState { child: reports.when( loading: () => const BackofficeLoadingState(message: '正在加载报告'), error: (_, _) => BackofficeErrorState( - onRetry: () => ref.invalidate(_reportsProvider(_filter)), + onRetry: () => ref.invalidate(_reportsProvider), ), - data: (items) => items.isEmpty - ? const BackofficeEmptyState( - icon: Icons.description_outlined, - title: '暂无报告', - description: '患者上传的待审核报告会显示在这里', - ) - : ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: items.length, - itemBuilder: (_, i) { - final r = items[i]; - return BackofficeSectionCard( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(12), + data: (allItems) { + final items = _filter.isEmpty + ? allItems + : allItems + .where((item) => item['status'] == _filter) + .toList(); + return items.isEmpty + ? const BackofficeEmptyState( + icon: Icons.description_outlined, + title: '暂无报告', + description: '患者上传的待审核报告会显示在这里', + ) + : ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: items.length, + itemBuilder: (_, i) { + final r = items[i]; + return BackofficeSectionCard( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(14), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: AppColors.iconBg, + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.description_outlined, + color: AppColors.primary, + ), ), - child: const Icon( - Icons.description_outlined, - color: AppColors.primary, + title: Text( + r['patientName'] ?? '', + style: const TextStyle( + fontWeight: FontWeight.w600, + ), + ), + subtitle: Text( + '${r['category'] ?? '未分类'} · ' + '${formatBackofficeDateTime(r['createdAt'])}', + ), + trailing: const Icon( + Icons.chevron_right, + color: AppColors.textHint, + ), + onTap: () => pushRoute( + ref, + 'doctorReportDetail', + params: {'id': r['id']?.toString() ?? ''}, ), ), - title: Text( - r['patientName'] ?? '', - style: const TextStyle(fontWeight: FontWeight.w600), - ), - subtitle: Text( - '${r['category'] ?? '未分类'} · ' - '${formatBackofficeDateTime(r['createdAt'])}', - ), - trailing: const Icon( - Icons.chevron_right, - color: AppColors.textHint, - ), - onTap: () => pushRoute( - ref, - 'doctorReportDetail', - params: {'id': r['id']?.toString() ?? ''}, - ), - ), - ); - }, - ), + ); + }, + ); + }, ), ), ], diff --git a/health_app/lib/pages/doctor/doctor_settings_page.dart b/health_app/lib/pages/doctor/doctor_settings_page.dart index b424500..1849d60 100644 --- a/health_app/lib/pages/doctor/doctor_settings_page.dart +++ b/health_app/lib/pages/doctor/doctor_settings_page.dart @@ -4,6 +4,7 @@ import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/backoffice_ui.dart'; +import '../../widgets/drawer_shell.dart'; class DoctorSettingsPage extends ConsumerWidget { const DoctorSettingsPage({super.key}); @@ -56,6 +57,13 @@ class DoctorSettingsPage extends ConsumerWidget { width: double.infinity, child: ElevatedButton( onPressed: () async { + final confirmed = await confirmAccountAction( + context, + title: '退出登录', + message: '确定退出当前医生账号吗?', + confirmLabel: '退出登录', + ); + if (!confirmed || !context.mounted) return; await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }, diff --git a/health_app/lib/pages/exercise/exercise_plan_page.dart b/health_app/lib/pages/exercise/exercise_plan_page.dart index b6d4abf..a92a56b 100644 --- a/health_app/lib/pages/exercise/exercise_plan_page.dart +++ b/health_app/lib/pages/exercise/exercise_plan_page.dart @@ -24,24 +24,10 @@ class EnterpriseExercisePlanPage extends ConsumerStatefulWidget { class _EnterpriseExercisePlanPageState extends ConsumerState { - Future>>? _future; final Set _busyItems = {}; final Set _deletingPlans = {}; - @override - void initState() { - super.initState(); - _load(); - } - - void _load() => setState(() { - _future = ref.read(exerciseServiceProvider).getPlans(); - }); - - Future _refresh() async { - _load(); - await _future; - } + Future _refresh() => ref.refresh(exercisePlansProvider.future); Future _toggleCheckIn(String itemId) async { if (itemId.isEmpty || _busyItems.contains(itemId)) return; @@ -49,7 +35,7 @@ class _EnterpriseExercisePlanPageState try { await ref.read(exerciseServiceProvider).checkIn(itemId); ref.invalidate(currentExercisePlanProvider); - _load(); + ref.invalidate(exercisePlansProvider); } catch (error) { if (mounted) { AppToast.show( @@ -88,7 +74,7 @@ class _EnterpriseExercisePlanPageState try { await ref.read(exerciseServiceProvider).deletePlan(id); ref.invalidate(currentExercisePlanProvider); - _load(); + ref.invalidate(exercisePlansProvider); if (mounted) { AppToast.show(context, '运动计划已删除', type: AppToastType.success); } @@ -119,9 +105,9 @@ class _EnterpriseExercisePlanPageState tooltip: '新建运动计划', onPressed: () => pushRoute(ref, 'exerciseCreate'), ), - body: AppFutureView>>( - future: _future, - onRetry: _load, + body: AppAsyncValueView>>( + value: ref.watch(exercisePlansProvider), + onRetry: () => ref.invalidate(exercisePlansProvider), errorTitle: '运动计划加载失败', onData: (_, plans) { if (plans.isEmpty) { diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index d461123..b3b76b2 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -13,6 +13,7 @@ import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/health_drawer.dart'; +import '../../widgets/keyboard_lift.dart'; import '../diet/diet_capture_page.dart'; import 'widgets/chat_messages_view.dart'; @@ -151,6 +152,7 @@ class _HomePageState extends ConsumerState return Scaffold( key: _scaffoldKey, + resizeToAvoidBottomInset: false, backgroundColor: const Color(0xFFFFFCFF), drawer: const HealthDrawer(), drawerEnableOpenDragGesture: false, @@ -178,7 +180,7 @@ class _HomePageState extends ConsumerState ), ), ), - _buildBottomBar(context), + KeyboardLift(child: _buildBottomBar(context)), ], ), ), @@ -300,7 +302,11 @@ class _HomePageState extends ConsumerState gradient: visual.gradient, borderRadius: BorderRadius.circular(6), ), - child: Icon(visual.icon, size: 13, color: Colors.white), + child: Icon( + visual.icon, + size: 13, + color: visual.iconColor, + ), ), const SizedBox(width: 6), Text( @@ -321,9 +327,8 @@ class _HomePageState extends ConsumerState ); } - ({String label, IconData icon, LinearGradient gradient}) _agentVisual( - ActiveAgent agent, - ) { + ({String label, IconData icon, LinearGradient gradient, Color iconColor}) + _agentVisual(ActiveAgent agent) { ({String label, AppModuleVisual visual}) fromModule( String label, AppModuleVisual visual, @@ -342,6 +347,7 @@ class _HomePageState extends ConsumerState label: module.label, icon: module.visual.icon, gradient: module.visual.gradient, + iconColor: Colors.white, ); } @@ -350,11 +356,13 @@ class _HomePageState extends ConsumerState label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.doctorGradient, + iconColor: Colors.white, ), _ => ( label: 'AI问诊', icon: LucideIcons.messageCircle, gradient: AppColors.primaryGradient, + iconColor: Colors.white, ), }; } diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index d4600ed..13ef4ba 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1287,11 +1287,20 @@ class ChatMessagesView extends ConsumerWidget { border: visual.borderColor, iconBg: visual.lightColor, accent: visual.color, + iconColor: Colors.white, verticalGradient: true, ); return switch (agent) { - ActiveAgent.health => fromModule(AppModuleVisuals.health), + ActiveAgent.health => _AgentColors( + gradient: AppModuleVisuals.health.gradient.colors, + bg: AppModuleVisuals.health.lightColor, + border: AppModuleVisuals.health.borderColor, + iconBg: AppModuleVisuals.health.lightColor, + accent: AppModuleVisuals.health.color, + iconColor: Colors.white, + verticalGradient: true, + ), ActiveAgent.diet => fromModule(AppModuleVisuals.diet), ActiveAgent.medication => fromModule(AppModuleVisuals.medication), ActiveAgent.report => fromModule(AppModuleVisuals.report), @@ -1302,6 +1311,7 @@ class ChatMessagesView extends ConsumerWidget { border: const Color(0xFFFFDFE7), iconBg: const Color(0xFFFFEEF2), accent: const Color(0xFFFF7F98), + iconColor: Colors.white, verticalGradient: true, ), _ => _AgentColors( @@ -1310,6 +1320,7 @@ class ChatMessagesView extends ConsumerWidget { border: const Color(0xFFE7E0FF), iconBg: const Color(0xFFF0EDFF), accent: AppColors.primary, + iconColor: Colors.white, ), }; } @@ -1317,7 +1328,7 @@ class ChatMessagesView extends ConsumerWidget { static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { return switch (agent) { ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'), - ActiveAgent.diet => (LucideIcons.utensils, '拍饮食', '拍照识别食物热量和营养成分'), + ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'), ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.consultation => ( LucideIcons.messageCircle, @@ -1967,7 +1978,7 @@ class _AgentMark extends StatelessWidget { ), ], ), - child: Center(child: Icon(icon, size: 32, color: Colors.white)), + child: Center(child: Icon(icon, size: 32, color: colors.iconColor)), ); } } @@ -2027,6 +2038,7 @@ class _AgentColors { final Color border; final Color iconBg; final Color accent; + final Color iconColor; final bool verticalGradient; const _AgentColors({ required this.gradient, @@ -2034,6 +2046,7 @@ class _AgentColors { required this.border, required this.iconBg, required this.accent, + required this.iconColor, this.verticalGradient = false, }); } diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index b99fe1b..9bed7e4 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -24,23 +24,9 @@ class MedicationListPage extends ConsumerStatefulWidget { } class _MedicationListPageState extends ConsumerState { - Future>>? _future; final Set _deleting = {}; - @override - void initState() { - super.initState(); - _load(); - } - - void _load() => setState(() { - _future = ref.read(medicationServiceProvider).getMedications(''); - }); - - Future _refresh() async { - _load(); - await _future; - } + Future _refresh() => ref.refresh(medicationListProvider.future); Future _confirmDelete(String id, String name) async { if (id.isEmpty || _deleting.contains(id)) return; @@ -68,7 +54,6 @@ class _MedicationListPageState extends ConsumerState { await ref.read(medicationServiceProvider).deleteMedication(id); ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); - _load(); if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success); } catch (error) { if (mounted) { @@ -97,9 +82,9 @@ class _MedicationListPageState extends ConsumerState { tooltip: '添加用药', onPressed: () => pushRoute(ref, 'medicationEdit'), ), - body: AppFutureView>>( - future: _future, - onRetry: _load, + body: AppAsyncValueView>>( + value: ref.watch(medicationListProvider), + onRetry: () => ref.invalidate(medicationListProvider), errorTitle: '用药信息加载失败', onData: (_, medications) { if (medications.isEmpty) { diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 35477b3..648c4af 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -521,10 +521,19 @@ class _NotificationRow extends StatelessWidget { width: 40, height: 40, decoration: BoxDecoration( - color: visual.lightColor, + color: visual.lightIconSurface + ? AppColors.device + : null, + gradient: visual.lightIconSurface + ? null + : visual.gradient, borderRadius: AppRadius.smBorder, ), - child: Icon(visual.icon, size: 21, color: visual.color), + child: Icon( + visual.icon, + size: 21, + color: visual.iconColor, + ), ), const SizedBox(width: 11), Expanded( @@ -657,46 +666,66 @@ class _NotificationVisual { final IconData icon; final Color color; final Color lightColor; + final Gradient gradient; + final Color iconColor; + final bool lightIconSurface; final String label; - const _NotificationVisual(this.icon, this.color, this.lightColor, this.label); + const _NotificationVisual( + this.icon, + this.color, + this.lightColor, + this.gradient, + this.iconColor, + this.lightIconSurface, + this.label, + ); - factory _NotificationVisual.fromModule(AppModuleVisual visual) { + factory _NotificationVisual.fromModule( + AppModuleVisual visual, { + Color iconColor = Colors.white, + bool lightIconSurface = false, + }) { return _NotificationVisual( visual.icon, visual.color, visual.lightColor, + visual.gradient, + iconColor, + lightIconSurface, visual.label, ); } factory _NotificationVisual.of(InAppNotification item) { - final kind = item.actionType ?? item.type; + final kind = (item.actionType ?? item.type).toLowerCase(); switch (kind) { case 'exercise': + case 'exercisereminder': return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': - return item.severity == 'critical' - ? const _NotificationVisual( - LucideIcons.triangleAlert, - AppColors.errorText, - AppColors.errorLight, - '紧急', - ) - : _NotificationVisual.fromModule(AppModuleVisuals.health); + case 'health_record_reminder': + return _NotificationVisual.fromModule(AppModuleVisuals.health); case 'report': - return item.severity == 'error' - ? const _NotificationVisual( - LucideIcons.fileWarning, - AppColors.errorText, - AppColors.errorLight, - '报告', - ) - : _NotificationVisual.fromModule(AppModuleVisuals.report); + return _NotificationVisual.fromModule(AppModuleVisuals.report); case 'medication': + case 'medicationreminder': return _NotificationVisual.fromModule(AppModuleVisuals.medication); case 'diet': return _NotificationVisual.fromModule(AppModuleVisuals.diet); + case 'followup': + case 'follow_up_reminder': + return _NotificationVisual.fromModule(AppModuleVisuals.followup); + case 'doctor': + case 'consultation': + return _NotificationVisual.fromModule(AppModuleVisuals.doctor); + case 'device': + case 'bluetooth': + return _NotificationVisual.fromModule( + AppModuleVisuals.device, + iconColor: Colors.white, + lightIconSurface: true, + ); default: return _NotificationVisual.fromModule(AppModuleVisuals.notification); } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index baff527..8588138 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -53,6 +53,7 @@ class _DietRecordListPageState extends ConsumerState { setState(() => _data.removeAt(index)); try { await ref.read(dietServiceProvider).deleteRecord(id); + ref.invalidate(dietRecordsProvider); if (mounted) { AppToast.show(context, '已删除', type: AppToastType.success); } @@ -205,59 +206,66 @@ class _DietRecordListPageState extends ConsumerState { padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5), child: GestureDetector( onTap: () => setState(() => _selectedDate = d), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: isSel - ? AppColors.primary - : (isToday ? DietPalette.primarySoft : Colors.white), - borderRadius: AppRadius.mdBorder, - border: Border.all( + child: CustomPaint( + foregroundPainter: cal > 0 + ? const _DietDateGradientBorderPainter() + : null, + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( color: isSel - ? Colors.transparent - : (isToday - ? const Color(0xFFC9D0FF) - : AppColors.borderLight), + ? AppColors.primary + : (isToday ? DietPalette.primarySoft : Colors.white), + borderRadius: AppRadius.mdBorder, + border: cal > 0 + ? null + : Border.all( + color: isSel + ? Colors.transparent + : (isToday + ? const Color(0xFFC9D0FF) + : AppColors.borderLight), + ), ), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - FittedBox( - fit: BoxFit.scaleDown, - child: Text( - isToday - ? '今天' - : ['一', '二', '三', '四', '五', '六', '日'][d.weekday - - 1], - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w700, - color: isSel ? Colors.white : AppColors.textHint, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + isToday + ? '今天' + : ['一', '二', '三', '四', '五', '六', '日'][d.weekday - + 1], + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: isSel ? Colors.white : AppColors.textHint, + ), ), ), - ), - const SizedBox(height: 3), - Text( - '${d.day}', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, - color: isSel ? Colors.white : AppColors.textPrimary, - ), - ), - if (cal > 0) ...[ - const SizedBox(height: 2), + const SizedBox(height: 3), Text( - '$cal', + '${d.day}', style: TextStyle( - fontSize: 11, - fontWeight: FontWeight.w600, - color: isSel ? Colors.white70 : AppColors.textHint, + fontSize: 18, + fontWeight: FontWeight.w700, + color: isSel ? Colors.white : AppColors.textPrimary, ), ), + if (cal > 0) ...[ + const SizedBox(height: 2), + Text( + '$cal', + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w600, + color: isSel ? Colors.white70 : AppColors.primary, + ), + ), + ], ], - ], + ), ), ), ), @@ -557,7 +565,7 @@ class _DietTrendPanel extends StatelessWidget { style: TextStyle( fontSize: 15, fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + color: AppColors.primary, ), ), const Spacer(), @@ -633,14 +641,15 @@ class _TrendChart extends StatelessWidget { '${data[i].toInt()}', style: const TextStyle( fontSize: 8, - color: AppColors.textHint, + color: AppColors.primary, ), ), const SizedBox(height: 2), Container( height: h, decoration: BoxDecoration( - color: data[i] > 0 ? DietPalette.primary : AppColors.border, + color: data[i] > 0 ? null : AppColors.border, + gradient: data[i] > 0 ? AppColors.primaryGradient : null, borderRadius: BorderRadius.circular(2), ), ), @@ -653,6 +662,32 @@ class _TrendChart extends StatelessWidget { } } +class _DietDateGradientBorderPainter extends CustomPainter { + const _DietDateGradientBorderPainter(); + + static const _strokeWidth = 1.5; + + @override + void paint(Canvas canvas, Size size) { + final rect = (Offset.zero & size).deflate(_strokeWidth / 2); + final paint = Paint() + ..style = PaintingStyle.stroke + ..strokeWidth = _strokeWidth + ..shader = AppColors.primaryGradient.createShader(rect) + ..isAntiAlias = true; + canvas.drawRRect( + RRect.fromRectAndRadius( + rect, + const Radius.circular(AppTheme.rMd - _strokeWidth / 2), + ), + paint, + ); + } + + @override + bool shouldRepaint(_DietDateGradientBorderPainter oldDelegate) => false; +} + class _SwipeAction extends StatelessWidget { final Key itemKey; final Widget child; @@ -709,19 +744,6 @@ class DietRecordDetailPage extends ConsumerStatefulWidget { } class _DietRecordDetailPageState extends ConsumerState { - late Future>> _recordsFuture; - - @override - void initState() { - super.initState(); - _recordsFuture = _loadRecords(); - } - - Future>> _loadRecords() => - ref.read(dietServiceProvider).getRecords(); - - void _reload() => setState(() => _recordsFuture = _loadRecords()); - String _formatRecordedAt(Object? value) { final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal(); if (date == null) return ''; @@ -742,22 +764,12 @@ class _DietRecordDetailPageState extends ConsumerState { ), title: const Text('饮食详情'), ), - body: FutureBuilder>>( - future: _recordsFuture, - builder: (ctx, snap) { - if (!snap.hasData) { - if (snap.hasError) { - return AppErrorState( - title: '饮食记录加载失败', - subtitle: '请检查网络后重新进入', - onRetry: _reload, - ); - } - return const Center( - child: CircularProgressIndicator(color: AppTheme.primary), - ); - } - final d = snap.data!.firstWhere( + body: AppAsyncValueView>>( + value: ref.watch(dietRecordsProvider), + errorTitle: '饮食记录加载失败', + onRetry: () => ref.invalidate(dietRecordsProvider), + onData: (ctx, records) { + final d = records.firstWhere( (r) => r['id']?.toString() == widget.id, orElse: () => {}, ); @@ -2370,18 +2382,6 @@ class FollowUpListPage extends ConsumerStatefulWidget { } class _FollowUpListPageState extends ConsumerState { - Future>>? _future; - - @override - void initState() { - super.initState(); - _load(); - } - - void _load() => setState(() { - _future = ref.read(followUpServiceProvider).getList(); - }); - @override Widget build(BuildContext context) { return GradientScaffold( @@ -2393,18 +2393,11 @@ class _FollowUpListPageState extends ConsumerState { title: const Text('复查随访'), centerTitle: true, ), - body: FutureBuilder>>( - future: _future, - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) { - return const Center( - child: CircularProgressIndicator(color: AppTheme.primaryLight), - ); - } - if (snap.hasError) { - return AppErrorState(title: '随访加载失败', onRetry: _load); - } - final list = snap.data ?? []; + body: AppAsyncValueView>>( + value: ref.watch(followUpListProvider), + errorTitle: '随访加载失败', + onRetry: () => ref.invalidate(followUpListProvider), + onData: (ctx, list) { if (list.isEmpty) { return Center( child: Column( diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 30cf6b0..a1289d1 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -57,11 +57,30 @@ final latestHealthProvider = FutureProvider>((ref) async { }); /// 用药列表 Provider -final medicationListProvider = - FutureProvider.autoDispose>>((ref) async { - final service = ref.watch(medicationServiceProvider); - return service.getList(); - }); +final medicationListProvider = FutureProvider>>(( + ref, +) async { + final service = ref.watch(medicationServiceProvider); + return service.getList(); +}); + +final exercisePlansProvider = FutureProvider>>(( + ref, +) async { + return ref.watch(exerciseServiceProvider).getPlans(); +}); + +final followUpListProvider = FutureProvider>>(( + ref, +) async { + return ref.watch(followUpServiceProvider).getList(); +}); + +final dietRecordsProvider = FutureProvider>>(( + ref, +) async { + return ref.watch(dietServiceProvider).getRecords(); +}); final medicationReminderProvider = FutureProvider>>(( ref, diff --git a/health_app/lib/widgets/admin_drawer.dart b/health_app/lib/widgets/admin_drawer.dart index 4eb3f79..d68df6e 100644 --- a/health_app/lib/widgets/admin_drawer.dart +++ b/health_app/lib/widgets/admin_drawer.dart @@ -12,99 +12,138 @@ class AdminDrawer extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final currentPage = ref.watch(adminPageProvider); + final pages = ref.read(adminPageProvider.notifier); + final routes = ref.read(routeStackProvider.notifier); + final authNotifier = ref.read(authProvider.notifier); return DrawerShell( child: SafeArea( - child: Column( - children: [ - Container( - margin: const EdgeInsets.all(14), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.88), - borderRadius: BorderRadius.circular(22), - border: Border.all( - color: Colors.white.withValues(alpha: 0.86), - width: 1.2, - ), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( children: [ Container( - width: 56, - height: 56, + margin: const EdgeInsets.all(14), + padding: const EdgeInsets.all(16), decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(20), - boxShadow: AppColors.buttonShadow, + color: Colors.white.withValues(alpha: 0.88), + borderRadius: BorderRadius.circular(22), + border: Border.all( + color: Colors.white.withValues(alpha: 0.86), + width: 1.2, + ), + boxShadow: AppColors.cardShadowLight, ), - child: const Icon( - Icons.admin_panel_settings, - size: 28, - color: Colors.white, - ), - ), - const SizedBox(width: 12), - const Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - '系统管理员', - style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, + Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(20), + boxShadow: AppColors.buttonShadow, + ), + child: const Icon( + Icons.admin_panel_settings, + size: 28, + color: Colors.white, ), ), - SizedBox(height: 3), - Text( - '医生与患者管理', - style: TextStyle( - fontSize: 12, - color: AppColors.textSecondary, + const SizedBox(width: 12), + const Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '系统管理员', + style: TextStyle( + fontSize: 17, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), + ), + SizedBox(height: 3), + Text( + '医生与患者管理', + style: TextStyle( + fontSize: 12, + color: AppColors.textSecondary, + ), + ), + ], ), ), ], ), ), + const SizedBox(height: 8), + _MenuItem( + icon: Icons.local_hospital, + label: '医生管理', + selected: currentPage == 'doctors', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('doctors'), + ), + ), + _MenuItem( + icon: Icons.people_outline, + label: '患者列表', + selected: currentPage == 'patients', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('patients'), + ), + ), + const Spacer(), + const Divider(height: 1, color: AppColors.divider), + const SizedBox(height: 8), + _MenuItem( + icon: Icons.delete_forever_outlined, + label: '删除账号', + danger: true, + onTap: () async { + final confirmed = await confirmAccountAction( + context, + title: '删除账号', + message: '此操作会永久删除当前管理员账号及相关数据,删除后无法恢复。', + confirmLabel: '确认删除', + ); + if (!confirmed || !context.mounted) return; + await runAfterDrawerClose(context, () async { + await ref + .read(apiClientProvider) + .delete('/api/user/account'); + await authNotifier.logout(); + routes.replace('login'); + }); + }, + ), + _MenuItem( + icon: Icons.logout, + label: '退出登录', + danger: true, + onTap: () async { + final confirmed = await confirmAccountAction( + context, + title: '退出登录', + message: '确定退出当前管理员账号吗?', + confirmLabel: '退出登录', + ); + if (!confirmed || !context.mounted) return; + await runAfterDrawerClose(context, () async { + await authNotifier.logout(); + routes.replace('login'); + }); + }, + ), + const SizedBox(height: 16), ], ), ), - const SizedBox(height: 8), - _MenuItem( - icon: Icons.local_hospital, - label: '医生管理', - selected: currentPage == 'doctors', - onTap: () { - Navigator.pop(context); - ref.read(adminPageProvider.notifier).set('doctors'); - }, - ), - _MenuItem( - icon: Icons.people_outline, - label: '患者列表', - selected: currentPage == 'patients', - onTap: () { - Navigator.pop(context); - ref.read(adminPageProvider.notifier).set('patients'); - }, - ), - const Spacer(), - const Divider(height: 1, color: AppColors.divider), - const SizedBox(height: 8), - _MenuItem( - icon: Icons.logout, - label: '退出登录', - danger: true, - onTap: () async { - Navigator.pop(context); - await ref.read(authProvider.notifier).logout(); - goRoute(ref, 'login'); - }, - ), - const SizedBox(height: 16), ], ), ), diff --git a/health_app/lib/widgets/app_future_view.dart b/health_app/lib/widgets/app_future_view.dart index 33c5328..2787df8 100644 --- a/health_app/lib/widgets/app_future_view.dart +++ b/health_app/lib/widgets/app_future_view.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/app_colors.dart'; import 'app_error_state.dart'; @@ -49,3 +50,35 @@ class AppFutureView extends StatelessWidget { ); } } + +/// Riverpod 异步数据视图。Provider 会保留最近一次成功数据,刷新时不清空页面。 +class AppAsyncValueView extends StatelessWidget { + final AsyncValue value; + final Widget Function(BuildContext context, T data) onData; + final VoidCallback? onRetry; + final Widget? loading; + final String? errorTitle; + + const AppAsyncValueView({ + super.key, + required this.value, + required this.onData, + this.onRetry, + this.loading, + this.errorTitle, + }); + + @override + Widget build(BuildContext context) => value.when( + skipLoadingOnRefresh: true, + skipLoadingOnReload: true, + data: (data) => onData(context, data), + error: (_, _) => + AppErrorState(title: errorTitle ?? '加载失败', onRetry: onRetry), + loading: () => + loading ?? + const Center( + child: CircularProgressIndicator(color: AppColors.primary), + ), + ); +} diff --git a/health_app/lib/widgets/ble_sync_dialog.dart b/health_app/lib/widgets/ble_sync_dialog.dart index 3bc28df..3ca90fa 100644 --- a/health_app/lib/widgets/ble_sync_dialog.dart +++ b/health_app/lib/widgets/ble_sync_dialog.dart @@ -32,7 +32,7 @@ Future showBleSyncDialog( width: 48, height: 48, decoration: BoxDecoration( - gradient: AppModuleVisuals.device.gradient, + color: AppModuleVisuals.device.color, borderRadius: AppRadius.mdBorder, ), child: Icon( diff --git a/health_app/lib/widgets/doctor_drawer.dart b/health_app/lib/widgets/doctor_drawer.dart index ec9fd19..10d4cd2 100644 --- a/health_app/lib/widgets/doctor_drawer.dart +++ b/health_app/lib/widgets/doctor_drawer.dart @@ -14,90 +14,108 @@ class DoctorDrawer extends ConsumerWidget { final auth = ref.watch(authProvider); final currentPage = ref.watch(doctorPageProvider); final name = auth.user?.name ?? '医生'; + final pages = ref.read(doctorPageProvider.notifier); + final routes = ref.read(routeStackProvider.notifier); + final authNotifier = ref.read(authProvider.notifier); return DrawerShell( child: SafeArea( - child: Column( - children: [ - _DrawerHeader( - name: name, - subtitle: '点击完善医生信息', - icon: Icons.medical_services_outlined, - onTap: () { - Navigator.pop(context); - pushRoute(ref, 'doctorProfile'); - }, + child: CustomScrollView( + slivers: [ + SliverFillRemaining( + hasScrollBody: false, + child: Column( + children: [ + _DrawerHeader( + name: name, + subtitle: '点击完善医生信息', + icon: Icons.medical_services_outlined, + onTap: () => runAfterDrawerClose( + context, + () => routes.push('doctorProfile'), + ), + ), + const SizedBox(height: 8), + _DrawerItem( + icon: Icons.dashboard_outlined, + label: '工作台', + selected: currentPage == 'dashboard', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('dashboard'), + ), + ), + _DrawerItem( + icon: Icons.people_outline, + label: '患者管理', + selected: currentPage == 'patients', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('patients'), + ), + ), + _DrawerItem( + icon: Icons.chat_outlined, + label: '问诊列表', + selected: currentPage == 'consultations', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('consultations'), + ), + ), + _DrawerItem( + icon: Icons.description_outlined, + label: '报告审核', + selected: currentPage == 'reports', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('reports'), + ), + ), + _DrawerItem( + icon: Icons.event_note_outlined, + label: '复查随访', + selected: currentPage == 'followups', + onTap: () => runAfterDrawerClose( + context, + () => pages.set('followups'), + ), + ), + const Spacer(), + const Divider(height: 1, color: AppColors.divider), + const SizedBox(height: 8), + _DrawerItem( + icon: Icons.settings_outlined, + label: '设置', + selected: false, + onTap: () => runAfterDrawerClose( + context, + () => routes.push('doctorSettings'), + ), + ), + _DrawerItem( + icon: Icons.logout, + label: '退出登录', + selected: false, + danger: true, + onTap: () async { + final confirmed = await confirmAccountAction( + context, + title: '退出登录', + message: '确定退出当前医生账号吗?', + confirmLabel: '退出登录', + ); + if (!confirmed || !context.mounted) return; + await runAfterDrawerClose(context, () async { + await authNotifier.logout(); + routes.replace('login'); + }); + }, + ), + const SizedBox(height: 16), + ], + ), ), - const SizedBox(height: 8), - _DrawerItem( - icon: Icons.dashboard_outlined, - label: '工作台', - selected: currentPage == 'dashboard', - onTap: () { - Navigator.pop(context); - ref.read(doctorPageProvider.notifier).set('dashboard'); - }, - ), - _DrawerItem( - icon: Icons.people_outline, - label: '患者管理', - selected: currentPage == 'patients', - onTap: () { - Navigator.pop(context); - ref.read(doctorPageProvider.notifier).set('patients'); - }, - ), - _DrawerItem( - icon: Icons.chat_outlined, - label: '问诊列表', - selected: currentPage == 'consultations', - onTap: () { - Navigator.pop(context); - ref.read(doctorPageProvider.notifier).set('consultations'); - }, - ), - _DrawerItem( - icon: Icons.description_outlined, - label: '报告审核', - selected: currentPage == 'reports', - onTap: () { - Navigator.pop(context); - ref.read(doctorPageProvider.notifier).set('reports'); - }, - ), - _DrawerItem( - icon: Icons.event_note_outlined, - label: '复查随访', - selected: currentPage == 'followups', - onTap: () { - Navigator.pop(context); - ref.read(doctorPageProvider.notifier).set('followups'); - }, - ), - const Spacer(), - const Divider(height: 1, color: AppColors.divider), - const SizedBox(height: 8), - _DrawerItem( - icon: Icons.settings_outlined, - label: '设置', - selected: false, - onTap: () { - Navigator.pop(context); - pushRoute(ref, 'doctorSettings'); - }, - ), - _DrawerItem( - icon: Icons.logout, - label: '退出登录', - selected: false, - danger: true, - onTap: () async { - Navigator.pop(context); - await ref.read(authProvider.notifier).logout(); - goRoute(ref, 'login'); - }, - ), - const SizedBox(height: 16), ], ), ), diff --git a/health_app/lib/widgets/drawer_shell.dart b/health_app/lib/widgets/drawer_shell.dart index 2770018..c6824a0 100644 --- a/health_app/lib/widgets/drawer_shell.dart +++ b/health_app/lib/widgets/drawer_shell.dart @@ -1,5 +1,44 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; +Future runAfterDrawerClose( + BuildContext context, + FutureOr Function() action, +) async { + Navigator.of(context).pop(); + await action(); +} + +Future confirmAccountAction( + BuildContext context, { + required String title, + required String message, + required String confirmLabel, +}) async { + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: Text(message), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext, false), + child: const Text('取消'), + ), + TextButton( + onPressed: () => Navigator.pop(dialogContext, true), + child: Text( + confirmLabel, + style: const TextStyle(color: Colors.red), + ), + ), + ], + ), + ) ?? + false; +} + class DrawerShell extends StatelessWidget { final double widthFactor; final Widget child; diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 1de8a22..78d3baf 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -213,11 +213,7 @@ class _HealthDashboard extends StatelessWidget { onTap: () => pushRoute(ref, 'trend'), ), titleColor: Colors.white, - backgroundGradient: const LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)], - ), + backgroundPainter: const _HealthDashboardBackgroundPainter(), child: latestHealth.when( data: (data) => _DashboardMetrics(data: data, ref: ref), loading: () => const SizedBox( @@ -392,7 +388,7 @@ class _NavigationSection extends StatelessWidget { colors: AppColors.medicationGradient.colors, ), _NavItem( - icon: LucideIcons.utensils, + icon: AppModuleVisuals.diet.icon, title: '饮食', route: 'dietRecords', colors: AppColors.dietGradient.colors, @@ -476,6 +472,7 @@ class _NavTile extends StatelessWidget { @override Widget build(BuildContext context) { + final isDevice = item.route == 'devices'; return InkWell( onTap: onTap, borderRadius: AppRadius.mdBorder, @@ -488,12 +485,16 @@ class _NavTile extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: _colors, - ), + color: isDevice ? AppColors.device : null, + gradient: isDevice + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: _colors, + ), borderRadius: AppRadius.mdBorder, + border: null, boxShadow: [ BoxShadow( color: _colors.last.withValues(alpha: 0.10), @@ -567,60 +568,96 @@ class _Panel extends StatelessWidget { final String title; final Widget child; final Widget? trailing; - final LinearGradient? backgroundGradient; + final CustomPainter? backgroundPainter; final Color titleColor; const _Panel({ required this.title, required this.child, this.trailing, - this.backgroundGradient, + this.backgroundPainter, this.titleColor = AppColors.textPrimary, }); @override Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(14), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( - gradient: - backgroundGradient ?? - LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - Colors.white.withValues(alpha: 0.92), - const Color(0xFFF8F4FF).withValues(alpha: 0.84), - ], - ), + color: backgroundPainter == null ? null : const Color(0xFFBAE6FD), + gradient: backgroundPainter != null + ? null + : LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [ + Colors.white.withValues(alpha: 0.92), + const Color(0xFFF8F4FF).withValues(alpha: 0.84), + ], + ), borderRadius: AppRadius.xlBorder, boxShadow: AppShadows.soft, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( + child: CustomPaint( + painter: backgroundPainter, + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Text( - title, - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: titleColor, + Row( + children: [ + Expanded( + child: Text( + title, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: titleColor, + ), + ), ), - ), + ?trailing, + ], ), - ?trailing, + const SizedBox(height: 12), + child, ], ), - const SizedBox(height: 12), - child, - ], + ), ), ); } } +class _HealthDashboardBackgroundPainter extends CustomPainter { + const _HealthDashboardBackgroundPainter(); + + @override + void paint(Canvas canvas, Size size) { + final rect = Offset.zero & size; + canvas.drawRect(rect, Paint()..color = const Color(0xFFBAE6FD)); + + void drawGlow(Alignment center, Color color) { + final paint = Paint() + ..shader = RadialGradient( + center: center, + radius: 1, + colors: [color, color.withValues(alpha: 0)], + stops: const [0, 0.8], + ).createShader(rect); + canvas.drawRect(rect, paint); + } + + drawGlow(Alignment.bottomRight, const Color(0xFF818CF8)); + drawGlow(const Alignment(0, 0.6), const Color(0xFF60A5FA)); + drawGlow(const Alignment(0.6, -0.6), const Color(0xFF6366F1)); + drawGlow(const Alignment(-0.6, -0.6), const Color(0xFF38BDF8)); + } + + @override + bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false; +} + class _TextAction extends StatelessWidget { final String label; final VoidCallback onTap; diff --git a/health_app/lib/widgets/keyboard_lift.dart b/health_app/lib/widgets/keyboard_lift.dart new file mode 100644 index 0000000..66c4810 --- /dev/null +++ b/health_app/lib/widgets/keyboard_lift.dart @@ -0,0 +1,17 @@ +import 'package:flutter/material.dart'; + +/// 只让输入区响应键盘高度,避免整棵页面随 MediaQuery 反复重建。 +class KeyboardLift extends StatelessWidget { + final Widget child; + + const KeyboardLift({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + final bottom = MediaQuery.viewInsetsOf(context).bottom; + return Padding( + padding: EdgeInsets.only(bottom: bottom), + child: child, + ); + } +} diff --git a/health_app/test/secondary_page_visuals_test.dart b/health_app/test/secondary_page_visuals_test.dart index d97907d..589d024 100644 --- a/health_app/test/secondary_page_visuals_test.dart +++ b/health_app/test/secondary_page_visuals_test.dart @@ -89,7 +89,9 @@ void main() { 'lib/pages/auth/login_page.dart', ).readAsStringSync().replaceAll('\r\n', '\n'); - expect(login, contains("Image.asset(\n _loginBg")); + expect(login, contains("static const _loginBg = 'assets/")); + expect(login, contains('Image.asset(')); + expect(login, contains('_loginBg,')); expect(login, contains('Colors.white.withValues(alpha: 0.88)')); expect(login, contains('fillColor: AppColors.cardInner')); expect(login, contains('showDragHandle: true')); @@ -202,7 +204,10 @@ void main() { contains('foregroundColor: AppColors.textPrimary'), ); expect(originalReportAction, contains('color: AppColors.border')); - expect(notifications, contains('BoxConstraints(minHeight: 82)')); + expect( + notifications, + contains('BoxConstraints(minHeight: showDivider ? 81 : 82)'), + ); expect(notifications, contains('width: 68')); expect(notifications, contains("const TextSpan(text: ' 条未读消息')")); expect(