feat: 三端抽屉重构 + 配色系统更新 + 后台管理页精调 + 键盘抬起组件

## 抽屉重构
- 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 更新
This commit is contained in:
MingNian
2026-07-19 19:11:30 +08:00
parent ae94ced2d5
commit 0d4fd88ce7
35 changed files with 1022 additions and 644 deletions

57
AGENTS.md Normal file
View File

@@ -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. 执行后进行完整验证并报告结果。
用户在具体任务中的直接指令始终优先于本文件。

View File

@@ -74,11 +74,28 @@ class _BootGateState extends ConsumerState<_BootGate> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_startTimeout();
}
void _startTimeout() {
_timer?.cancel();
_timer = Timer(const Duration(seconds: 8), () { _timer = Timer(const Duration(seconds: 8), () {
if (mounted) setState(() => _timedOut = true); 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 @override
void dispose() { void dispose() {
_timer?.cancel(); _timer?.cancel();
@@ -87,6 +104,7 @@ class _BootGateState extends ConsumerState<_BootGate> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
ref.listen<bool>(appReadyProvider, _handleReadyChanged);
final ready = ref.watch(appReadyProvider) || _timedOut; final ready = ref.watch(appReadyProvider) || _timedOut;
return Stack( return Stack(
children: [ children: [
@@ -135,9 +153,21 @@ class _RootNavigator extends ConsumerWidget {
} }
return PopScope( return PopScope(
canPop: stack.length <= 1, canPop: false,
onPopInvokedWithResult: (didPop, result) { 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( child: Navigator(
pages: [ pages: [
@@ -148,8 +178,12 @@ class _RootNavigator extends ConsumerWidget {
child: buildPage(stack[index], ref), child: buildPage(stack[index], ref),
), ),
], ],
onDidRemovePage: (_) { onDidRemovePage: (page) {
if (ref.read(routeStackProvider).length > 1) popRoute(ref); 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);
}, },
), ),
); );

View File

@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: kReleaseMode defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai' ? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://10.4.159.130:5000', : 'http://10.4.165.54:5000',
); );
class ApiException implements Exception { class ApiException implements Exception {

View File

@@ -26,9 +26,9 @@ class AppColors {
static const Color medication = Color(0xFF00B8D9); static const Color medication = Color(0xFF00B8D9);
static const Color medicationLight = Color(0xFFEAFBFF); static const Color medicationLight = Color(0xFFEAFBFF);
static const Color medicationBorder = Color(0xFFB8F0FA); static const Color medicationBorder = Color(0xFFB8F0FA);
static const Color exercise = Color(0xFF566FD4); static const Color exercise = Color(0xFF7C3AED);
static const Color exerciseLight = Color(0xFFF1F3FD); static const Color exerciseLight = Color(0xFFF3E8FF);
static const Color exerciseBorder = Color(0xFFD7DCF6); static const Color exerciseBorder = Color(0xFFDDD6FE);
static const Color report = Color(0xFF6366F1); static const Color report = Color(0xFF6366F1);
static const Color reportLight = Color(0xFFEFFBFF); static const Color reportLight = Color(0xFFEFFBFF);
static const Color reportBorder = Color(0xFFBAE6FD); static const Color reportBorder = Color(0xFFBAE6FD);
@@ -168,7 +168,7 @@ class AppColors {
static const LinearGradient healthGradient = LinearGradient( static const LinearGradient healthGradient = LinearGradient(
begin: Alignment.centerLeft, begin: Alignment.centerLeft,
end: Alignment.centerRight, end: Alignment.centerRight,
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)], colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
); );
static const LinearGradient medicationGradient = LinearGradient( static const LinearGradient medicationGradient = LinearGradient(
@@ -180,7 +180,7 @@ class AppColors {
static const LinearGradient exerciseGradient = LinearGradient( static const LinearGradient exerciseGradient = LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)], colors: [Color(0xFFC4B5FD), Color(0xFF7C3AED)],
); );
static const LinearGradient reportGradient = LinearGradient( static const LinearGradient reportGradient = LinearGradient(

View File

@@ -95,7 +95,7 @@ class AppModuleVisuals {
static const diet = AppModuleVisual( static const diet = AppModuleVisual(
module: AppModule.diet, module: AppModule.diet,
label: '饮食', label: '饮食',
icon: LucideIcons.utensils, icon: LucideIcons.salad,
color: AppColors.diet, color: AppColors.diet,
lightColor: AppColors.dietLight, lightColor: AppColors.dietLight,
borderColor: AppColors.dietBorder, borderColor: AppColors.dietBorder,

View File

@@ -548,6 +548,7 @@ class GradientScaffold extends StatelessWidget {
final Widget? drawer; final Widget? drawer;
final Widget? bottomNavigationBar; final Widget? bottomNavigationBar;
final bool extendBody; final bool extendBody;
final bool resizeToAvoidBottomInset;
const GradientScaffold({ const GradientScaffold({
super.key, super.key,
@@ -557,6 +558,7 @@ class GradientScaffold extends StatelessWidget {
this.drawer, this.drawer,
this.bottomNavigationBar, this.bottomNavigationBar,
this.extendBody = false, this.extendBody = false,
this.resizeToAvoidBottomInset = true,
}); });
@override @override
@@ -572,6 +574,7 @@ class GradientScaffold extends StatelessWidget {
drawer: drawer, drawer: drawer,
bottomNavigationBar: bottomNavigationBar, bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody, extendBody: extendBody,
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
); );
} }

View File

@@ -121,6 +121,7 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
), ),
body: BackofficeSurface( body: BackofficeSurface(
child: SingleChildScrollView( child: SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@@ -97,14 +97,7 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) { ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) {
if (previous != null && previous != next) _load(); if (previous != null && previous != next) _load();
}); });
return Scaffold( final body = _loading
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: '正在加载医生') ? const BackofficeLoadingState(message: '正在加载医生')
: _error != null : _error != null
? BackofficeErrorState(message: _error!, onRetry: _load) ? BackofficeErrorState(message: _error!, onRetry: _load)
@@ -132,9 +125,7 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
child: Icon( child: Icon(
Icons.local_hospital, Icons.local_hospital,
size: 20, size: 20,
color: active color: active ? AppColors.success : AppColors.textHint,
? AppColors.success
: AppColors.textHint,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -204,8 +195,7 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
'title': d['title']?.toString() ?? '', 'title': d['title']?.toString() ?? '',
'department': d['department']?.toString() ?? '', 'department': d['department']?.toString() ?? '',
'direction': 'direction':
d['professionalDirection']?.toString() ?? d['professionalDirection']?.toString() ?? '',
'',
}, },
); );
} }
@@ -231,7 +221,20 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
), ),
); );
}, },
);
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),
), ),
),
],
); );
} }
} }

View File

@@ -16,15 +16,30 @@ class AdminPageNotifier extends Notifier<String> {
void set(String page) => state = page; void set(String page) => state = page;
} }
class AdminHomePage extends ConsumerWidget { class AdminHomePage extends ConsumerStatefulWidget {
const AdminHomePage({super.key}); const AdminHomePage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<AdminHomePage> createState() => _AdminHomePageState();
final page = ref.watch(adminPageProvider); }
return Scaffold( class _AdminHomePageState extends ConsumerState<AdminHomePage> {
final List<Widget?> _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, backgroundColor: AppColors.pageGrey,
drawerEnableOpenDragGesture: true,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
@@ -36,11 +51,13 @@ class AdminHomePage extends ConsumerWidget {
), ),
drawer: const AdminDrawer(), drawer: const AdminDrawer(),
body: BackofficeSurface( body: BackofficeSurface(
child: switch (page) { child: IndexedStack(
'doctors' => const AdminDoctorsPage(), index: pageIndex,
'patients' => const AdminPatientsPage(), children: _pages
_ => const AdminDoctorsPage(), .map((page) => page ?? const SizedBox.shrink())
}, .toList(growable: false),
),
),
), ),
); );
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart'; import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../providers/consultation_provider.dart'; import '../../providers/consultation_provider.dart';
import '../../widgets/keyboard_lift.dart';
/// 问诊对话页 — 完整的 AI 分身聊天界面 /// 问诊对话页 — 完整的 AI 分身聊天界面
class DoctorChatPage extends ConsumerStatefulWidget { class DoctorChatPage extends ConsumerStatefulWidget {
@@ -62,6 +63,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
final canSend = state.status == 'AiTalking' && !state.isSending; final canSend = state.status == 'AiTalking' && !state.isSending;
return GradientScaffold( return GradientScaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar( appBar: AppBar(
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
title: Column( title: Column(
@@ -113,7 +115,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
: Column( : Column(
children: [ children: [
Expanded(child: _buildMessageList(state)), Expanded(child: _buildMessageList(state)),
_buildInputBar(canSend), KeyboardLift(child: _buildInputBar(canSend)),
], ],
), ),
); );

View File

@@ -425,9 +425,9 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
onPressed: () => pushRoute(ref, 'deviceScan'), onPressed: () => pushRoute(ref, 'deviceScan'),
style: IconButton.styleFrom( style: IconButton.styleFrom(
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: AppColors.primaryDark, foregroundColor: AppColors.device,
fixedSize: const Size(40, 40), fixedSize: const Size(40, 40),
side: const BorderSide(color: AppColors.primaryLight), side: const BorderSide(color: AppColors.deviceBorder),
shape: const CircleBorder(), shape: const CircleBorder(),
), ),
icon: const Icon(Icons.add_rounded), icon: const Icon(Icons.add_rounded),
@@ -604,10 +604,10 @@ class _DevicesHeader extends StatelessWidget {
width: 50, width: 50,
height: 50, height: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _deviceVisual.lightColor, color: AppColors.device,
borderRadius: AppRadius.smBorder, 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), const SizedBox(width: 14),
Expanded( Expanded(
@@ -671,14 +671,10 @@ class _DeviceSection extends StatelessWidget {
width: 34, width: 34,
height: 34, height: 34,
decoration: BoxDecoration( decoration: BoxDecoration(
color: _deviceVisual.lightColor, color: AppColors.device,
borderRadius: AppRadius.smBorder, borderRadius: AppRadius.smBorder,
), ),
child: Icon( child: Icon(type.icon, size: 19, color: Colors.white),
type.icon,
size: 19,
color: _deviceVisual.color,
),
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text( Text(
@@ -733,7 +729,6 @@ class _BoundDeviceTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final accent = connected ? AppColors.successText : AppColors.textHint;
final availability = deviceSyncAvailabilityLabel(device.type); final availability = deviceSyncAvailabilityLabel(device.type);
return AnimatedContainer( return AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
@@ -750,10 +745,10 @@ class _BoundDeviceTile extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: connected ? AppColors.successLight : AppColors.deviceLight, color: AppColors.device,
borderRadius: AppRadius.smBorder, 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), const SizedBox(width: 12),
Expanded( Expanded(

View File

@@ -445,12 +445,12 @@ class _DeviceResultTile extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.deviceLight, color: AppColors.device,
borderRadius: AppRadius.smBorder, borderRadius: AppRadius.smBorder,
), ),
child: Icon( child: Icon(
type?.icon ?? Icons.bluetooth_rounded, type?.icon ?? Icons.bluetooth_rounded,
color: AppColors.device, color: Colors.white,
size: 25, size: 25,
), ),
), ),
@@ -565,14 +565,13 @@ class _ScanPulse extends StatelessWidget {
width: 64, width: 64,
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.device,
borderRadius: AppRadius.xlBorder, borderRadius: AppRadius.xlBorder,
border: Border.all(color: AppColors.borderLight),
), ),
child: const Icon( child: const Icon(
Icons.bluetooth_searching_rounded, Icons.bluetooth_searching_rounded,
size: 32, size: 32,
color: AppColors.device, color: Colors.white,
), ),
), ),
], ],

View File

@@ -378,6 +378,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
final state = ref.watch(dietProvider); final state = ref.watch(dietProvider);
return SingleChildScrollView( return SingleChildScrollView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,

View File

@@ -229,7 +229,9 @@ class _StatCard extends StatelessWidget {
child: Icon(icon, color: color, size: 20), child: Icon(icon, color: color, size: 20),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column( Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
@@ -242,6 +244,8 @@ class _StatCard extends StatelessWidget {
), ),
Text( Text(
label, label,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
fontSize: 12, fontSize: 12,
color: AppColors.textHint, color: AppColors.textHint,
@@ -249,6 +253,7 @@ class _StatCard extends StatelessWidget {
), ),
], ],
), ),
),
], ],
), ),
), ),

View File

@@ -78,6 +78,7 @@ class _DoctorFollowUpEditPageState
), ),
body: BackofficeSurface( body: BackofficeSurface(
child: ListView( child: ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
const Text( const Text(

View File

@@ -62,22 +62,22 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Wrap(
spacing: 8,
runSpacing: 8,
crossAxisAlignment: WrapCrossAlignment.center,
children: [ children: [
_Filt('全部', _filter == '', () => setState(() => _filter = '')), _Filt('全部', _filter == '', () => setState(() => _filter = '')),
const SizedBox(width: 8),
_Filt( _Filt(
'待完成', '待完成',
_filter == 'Upcoming', _filter == 'Upcoming',
() => setState(() => _filter = 'Upcoming'), () => setState(() => _filter = 'Upcoming'),
), ),
const SizedBox(width: 8),
_Filt( _Filt(
'已完成', '已完成',
_filter == 'Completed', _filter == 'Completed',
() => setState(() => _filter = 'Completed'), () => setState(() => _filter = 'Completed'),
), ),
const Spacer(),
IconButton( IconButton(
icon: const Icon(Icons.add, color: AppColors.primary), icon: const Icon(Icons.add, color: AppColors.primary),
onPressed: () => pushRoute(ref, 'doctorFollowUpEdit'), onPressed: () => pushRoute(ref, 'doctorFollowUpEdit'),

View File

@@ -9,12 +9,41 @@ import 'doctor_consultations_page.dart';
import 'doctor_reports_page.dart'; import 'doctor_reports_page.dart';
import 'doctor_followups_page.dart'; import 'doctor_followups_page.dart';
class DoctorHomePage extends ConsumerWidget { class DoctorHomePage extends ConsumerStatefulWidget {
const DoctorHomePage({super.key}); const DoctorHomePage({super.key});
@override @override
Widget build(BuildContext context, WidgetRef ref) { ConsumerState<DoctorHomePage> createState() => _DoctorHomePageState();
}
class _DoctorHomePageState extends ConsumerState<DoctorHomePage> {
final List<Widget?> _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 page = ref.watch(doctorPageProvider);
final pageIndex = switch (page) {
'patients' => 1,
'consultations' => 2,
'reports' => 3,
'followups' => 4,
_ => 0,
};
_pages[pageIndex] ??= _createPage(pageIndex);
return PopScope( return PopScope(
canPop: page == 'dashboard', canPop: page == 'dashboard',
@@ -39,20 +68,19 @@ class DoctorHomePage extends ConsumerWidget {
), ),
), ),
drawer: const DoctorDrawer(), 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) { String _titleFor(String page) => switch (page) {
'dashboard' => '工作台', 'dashboard' => '工作台',
'patients' => '患者管理', 'patients' => '患者管理',

View File

@@ -132,6 +132,7 @@ class _DoctorReportDetailPageState
final fileUrl = data['fileUrl'] as String?; final fileUrl = data['fileUrl'] as String?;
return ListView( return ListView(
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
children: [ children: [
// 患者+分类信息 // 患者+分类信息

View File

@@ -6,18 +6,11 @@ import '../../providers/auth_provider.dart';
import '../../utils/backoffice_formatters.dart'; import '../../utils/backoffice_formatters.dart';
import '../../widgets/backoffice_ui.dart'; import '../../widgets/backoffice_ui.dart';
final _reportsProvider = final _reportsProvider = FutureProvider<List<Map<String, dynamic>>>((
FutureProvider.family<List<Map<String, dynamic>>, String>((
ref, ref,
status,
) async { ) async {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
final params = <String, dynamic>{}; final res = await api.get('/api/doctor/reports');
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<Map<String, dynamic>>() ?? []; return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
}); });
@@ -32,12 +25,14 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final reports = ref.watch(_reportsProvider(_filter)); final reports = ref.watch(_reportsProvider);
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Row( child: Wrap(
spacing: 8,
runSpacing: 8,
children: [ children: [
_FilterChip( _FilterChip(
'全部', '全部',
@@ -45,14 +40,12 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
_filter, _filter,
() => setState(() => _filter = ''), () => setState(() => _filter = ''),
), ),
const SizedBox(width: 8),
_FilterChip( _FilterChip(
'待审核', '待审核',
'PendingDoctor', 'PendingDoctor',
_filter, _filter,
() => setState(() => _filter = 'PendingDoctor'), () => setState(() => _filter = 'PendingDoctor'),
), ),
const SizedBox(width: 8),
_FilterChip( _FilterChip(
'已审核', '已审核',
'DoctorReviewed', 'DoctorReviewed',
@@ -66,9 +59,15 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
child: reports.when( child: reports.when(
loading: () => const BackofficeLoadingState(message: '正在加载报告'), loading: () => const BackofficeLoadingState(message: '正在加载报告'),
error: (_, _) => BackofficeErrorState( error: (_, _) => BackofficeErrorState(
onRetry: () => ref.invalidate(_reportsProvider(_filter)), onRetry: () => ref.invalidate(_reportsProvider),
), ),
data: (items) => items.isEmpty data: (allItems) {
final items = _filter.isEmpty
? allItems
: allItems
.where((item) => item['status'] == _filter)
.toList();
return items.isEmpty
? const BackofficeEmptyState( ? const BackofficeEmptyState(
icon: Icons.description_outlined, icon: Icons.description_outlined,
title: '暂无报告', title: '暂无报告',
@@ -98,7 +97,9 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
), ),
title: Text( title: Text(
r['patientName'] ?? '', r['patientName'] ?? '',
style: const TextStyle(fontWeight: FontWeight.w600), style: const TextStyle(
fontWeight: FontWeight.w600,
),
), ),
subtitle: Text( subtitle: Text(
'${r['category'] ?? '未分类'} · ' '${r['category'] ?? '未分类'} · '
@@ -116,7 +117,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
), ),
); );
}, },
), );
},
), ),
), ),
], ],

View File

@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/backoffice_ui.dart'; import '../../widgets/backoffice_ui.dart';
import '../../widgets/drawer_shell.dart';
class DoctorSettingsPage extends ConsumerWidget { class DoctorSettingsPage extends ConsumerWidget {
const DoctorSettingsPage({super.key}); const DoctorSettingsPage({super.key});
@@ -56,6 +57,13 @@ class DoctorSettingsPage extends ConsumerWidget {
width: double.infinity, width: double.infinity,
child: ElevatedButton( child: ElevatedButton(
onPressed: () async { onPressed: () async {
final confirmed = await confirmAccountAction(
context,
title: '退出登录',
message: '确定退出当前医生账号吗?',
confirmLabel: '退出登录',
);
if (!confirmed || !context.mounted) return;
await ref.read(authProvider.notifier).logout(); await ref.read(authProvider.notifier).logout();
goRoute(ref, 'login'); goRoute(ref, 'login');
}, },

View File

@@ -24,24 +24,10 @@ class EnterpriseExercisePlanPage extends ConsumerStatefulWidget {
class _EnterpriseExercisePlanPageState class _EnterpriseExercisePlanPageState
extends ConsumerState<EnterpriseExercisePlanPage> { extends ConsumerState<EnterpriseExercisePlanPage> {
Future<List<Map<String, dynamic>>>? _future;
final Set<String> _busyItems = {}; final Set<String> _busyItems = {};
final Set<String> _deletingPlans = {}; final Set<String> _deletingPlans = {};
@override Future<void> _refresh() => ref.refresh(exercisePlansProvider.future);
void initState() {
super.initState();
_load();
}
void _load() => setState(() {
_future = ref.read(exerciseServiceProvider).getPlans();
});
Future<void> _refresh() async {
_load();
await _future;
}
Future<void> _toggleCheckIn(String itemId) async { Future<void> _toggleCheckIn(String itemId) async {
if (itemId.isEmpty || _busyItems.contains(itemId)) return; if (itemId.isEmpty || _busyItems.contains(itemId)) return;
@@ -49,7 +35,7 @@ class _EnterpriseExercisePlanPageState
try { try {
await ref.read(exerciseServiceProvider).checkIn(itemId); await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
_load(); ref.invalidate(exercisePlansProvider);
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
AppToast.show( AppToast.show(
@@ -88,7 +74,7 @@ class _EnterpriseExercisePlanPageState
try { try {
await ref.read(exerciseServiceProvider).deletePlan(id); await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
_load(); ref.invalidate(exercisePlansProvider);
if (mounted) { if (mounted) {
AppToast.show(context, '运动计划已删除', type: AppToastType.success); AppToast.show(context, '运动计划已删除', type: AppToastType.success);
} }
@@ -119,9 +105,9 @@ class _EnterpriseExercisePlanPageState
tooltip: '新建运动计划', tooltip: '新建运动计划',
onPressed: () => pushRoute(ref, 'exerciseCreate'), onPressed: () => pushRoute(ref, 'exerciseCreate'),
), ),
body: AppFutureView<List<Map<String, dynamic>>>( body: AppAsyncValueView<List<Map<String, dynamic>>>(
future: _future, value: ref.watch(exercisePlansProvider),
onRetry: _load, onRetry: () => ref.invalidate(exercisePlansProvider),
errorTitle: '运动计划加载失败', errorTitle: '运动计划加载失败',
onData: (_, plans) { onData: (_, plans) {
if (plans.isEmpty) { if (plans.isEmpty) {

View File

@@ -13,6 +13,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart'; import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart'; import '../../widgets/health_drawer.dart';
import '../../widgets/keyboard_lift.dart';
import '../diet/diet_capture_page.dart'; import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart'; import 'widgets/chat_messages_view.dart';
@@ -151,6 +152,7 @@ class _HomePageState extends ConsumerState<HomePage>
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
resizeToAvoidBottomInset: false,
backgroundColor: const Color(0xFFFFFCFF), backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(), drawer: const HealthDrawer(),
drawerEnableOpenDragGesture: false, drawerEnableOpenDragGesture: false,
@@ -178,7 +180,7 @@ class _HomePageState extends ConsumerState<HomePage>
), ),
), ),
), ),
_buildBottomBar(context), KeyboardLift(child: _buildBottomBar(context)),
], ],
), ),
), ),
@@ -300,7 +302,11 @@ class _HomePageState extends ConsumerState<HomePage>
gradient: visual.gradient, gradient: visual.gradient,
borderRadius: BorderRadius.circular(6), 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), const SizedBox(width: 6),
Text( Text(
@@ -321,9 +327,8 @@ class _HomePageState extends ConsumerState<HomePage>
); );
} }
({String label, IconData icon, LinearGradient gradient}) _agentVisual( ({String label, IconData icon, LinearGradient gradient, Color iconColor})
ActiveAgent agent, _agentVisual(ActiveAgent agent) {
) {
({String label, AppModuleVisual visual}) fromModule( ({String label, AppModuleVisual visual}) fromModule(
String label, String label,
AppModuleVisual visual, AppModuleVisual visual,
@@ -342,6 +347,7 @@ class _HomePageState extends ConsumerState<HomePage>
label: module.label, label: module.label,
icon: module.visual.icon, icon: module.visual.icon,
gradient: module.visual.gradient, gradient: module.visual.gradient,
iconColor: Colors.white,
); );
} }
@@ -350,11 +356,13 @@ class _HomePageState extends ConsumerState<HomePage>
label: 'AI问诊', label: 'AI问诊',
icon: LucideIcons.messageCircle, icon: LucideIcons.messageCircle,
gradient: AppColors.doctorGradient, gradient: AppColors.doctorGradient,
iconColor: Colors.white,
), ),
_ => ( _ => (
label: 'AI问诊', label: 'AI问诊',
icon: LucideIcons.messageCircle, icon: LucideIcons.messageCircle,
gradient: AppColors.primaryGradient, gradient: AppColors.primaryGradient,
iconColor: Colors.white,
), ),
}; };
} }

View File

@@ -1287,11 +1287,20 @@ class ChatMessagesView extends ConsumerWidget {
border: visual.borderColor, border: visual.borderColor,
iconBg: visual.lightColor, iconBg: visual.lightColor,
accent: visual.color, accent: visual.color,
iconColor: Colors.white,
verticalGradient: true, verticalGradient: true,
); );
return switch (agent) { 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.diet => fromModule(AppModuleVisuals.diet),
ActiveAgent.medication => fromModule(AppModuleVisuals.medication), ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
ActiveAgent.report => fromModule(AppModuleVisuals.report), ActiveAgent.report => fromModule(AppModuleVisuals.report),
@@ -1302,6 +1311,7 @@ class ChatMessagesView extends ConsumerWidget {
border: const Color(0xFFFFDFE7), border: const Color(0xFFFFDFE7),
iconBg: const Color(0xFFFFEEF2), iconBg: const Color(0xFFFFEEF2),
accent: const Color(0xFFFF7F98), accent: const Color(0xFFFF7F98),
iconColor: Colors.white,
verticalGradient: true, verticalGradient: true,
), ),
_ => _AgentColors( _ => _AgentColors(
@@ -1310,6 +1320,7 @@ class ChatMessagesView extends ConsumerWidget {
border: const Color(0xFFE7E0FF), border: const Color(0xFFE7E0FF),
iconBg: const Color(0xFFF0EDFF), iconBg: const Color(0xFFF0EDFF),
accent: AppColors.primary, accent: AppColors.primary,
iconColor: Colors.white,
), ),
}; };
} }
@@ -1317,7 +1328,7 @@ class ChatMessagesView extends ConsumerWidget {
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) { static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
return switch (agent) { return switch (agent) {
ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'), ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'),
ActiveAgent.diet => (LucideIcons.utensils, '拍饮食', '拍照识别食物热量和营养成分'), ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'),
ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'),
ActiveAgent.consultation => ( ActiveAgent.consultation => (
LucideIcons.messageCircle, 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 border;
final Color iconBg; final Color iconBg;
final Color accent; final Color accent;
final Color iconColor;
final bool verticalGradient; final bool verticalGradient;
const _AgentColors({ const _AgentColors({
required this.gradient, required this.gradient,
@@ -2034,6 +2046,7 @@ class _AgentColors {
required this.border, required this.border,
required this.iconBg, required this.iconBg,
required this.accent, required this.accent,
required this.iconColor,
this.verticalGradient = false, this.verticalGradient = false,
}); });
} }

View File

@@ -24,23 +24,9 @@ class MedicationListPage extends ConsumerStatefulWidget {
} }
class _MedicationListPageState extends ConsumerState<MedicationListPage> { class _MedicationListPageState extends ConsumerState<MedicationListPage> {
Future<List<Map<String, dynamic>>>? _future;
final Set<String> _deleting = {}; final Set<String> _deleting = {};
@override Future<void> _refresh() => ref.refresh(medicationListProvider.future);
void initState() {
super.initState();
_load();
}
void _load() => setState(() {
_future = ref.read(medicationServiceProvider).getMedications('');
});
Future<void> _refresh() async {
_load();
await _future;
}
Future<void> _confirmDelete(String id, String name) async { Future<void> _confirmDelete(String id, String name) async {
if (id.isEmpty || _deleting.contains(id)) return; if (id.isEmpty || _deleting.contains(id)) return;
@@ -68,7 +54,6 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
await ref.read(medicationServiceProvider).deleteMedication(id); await ref.read(medicationServiceProvider).deleteMedication(id);
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider); ref.invalidate(medicationReminderProvider);
_load();
if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success); if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success);
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
@@ -97,9 +82,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
tooltip: '添加用药', tooltip: '添加用药',
onPressed: () => pushRoute(ref, 'medicationEdit'), onPressed: () => pushRoute(ref, 'medicationEdit'),
), ),
body: AppFutureView<List<Map<String, dynamic>>>( body: AppAsyncValueView<List<Map<String, dynamic>>>(
future: _future, value: ref.watch(medicationListProvider),
onRetry: _load, onRetry: () => ref.invalidate(medicationListProvider),
errorTitle: '用药信息加载失败', errorTitle: '用药信息加载失败',
onData: (_, medications) { onData: (_, medications) {
if (medications.isEmpty) { if (medications.isEmpty) {

View File

@@ -521,10 +521,19 @@ class _NotificationRow extends StatelessWidget {
width: 40, width: 40,
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: visual.lightColor, color: visual.lightIconSurface
? AppColors.device
: null,
gradient: visual.lightIconSurface
? null
: visual.gradient,
borderRadius: AppRadius.smBorder, 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), const SizedBox(width: 11),
Expanded( Expanded(
@@ -657,46 +666,66 @@ class _NotificationVisual {
final IconData icon; final IconData icon;
final Color color; final Color color;
final Color lightColor; final Color lightColor;
final Gradient gradient;
final Color iconColor;
final bool lightIconSurface;
final String label; 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( return _NotificationVisual(
visual.icon, visual.icon,
visual.color, visual.color,
visual.lightColor, visual.lightColor,
visual.gradient,
iconColor,
lightIconSurface,
visual.label, visual.label,
); );
} }
factory _NotificationVisual.of(InAppNotification item) { factory _NotificationVisual.of(InAppNotification item) {
final kind = item.actionType ?? item.type; final kind = (item.actionType ?? item.type).toLowerCase();
switch (kind) { switch (kind) {
case 'exercise': case 'exercise':
case 'exercisereminder':
return _NotificationVisual.fromModule(AppModuleVisuals.exercise); return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
case 'health': case 'health':
return item.severity == 'critical' case 'health_record_reminder':
? const _NotificationVisual( return _NotificationVisual.fromModule(AppModuleVisuals.health);
LucideIcons.triangleAlert,
AppColors.errorText,
AppColors.errorLight,
'紧急',
)
: _NotificationVisual.fromModule(AppModuleVisuals.health);
case 'report': case 'report':
return item.severity == 'error' return _NotificationVisual.fromModule(AppModuleVisuals.report);
? const _NotificationVisual(
LucideIcons.fileWarning,
AppColors.errorText,
AppColors.errorLight,
'报告',
)
: _NotificationVisual.fromModule(AppModuleVisuals.report);
case 'medication': case 'medication':
case 'medicationreminder':
return _NotificationVisual.fromModule(AppModuleVisuals.medication); return _NotificationVisual.fromModule(AppModuleVisuals.medication);
case 'diet': case 'diet':
return _NotificationVisual.fromModule(AppModuleVisuals.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: default:
return _NotificationVisual.fromModule(AppModuleVisuals.notification); return _NotificationVisual.fromModule(AppModuleVisuals.notification);
} }

View File

@@ -53,6 +53,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
setState(() => _data.removeAt(index)); setState(() => _data.removeAt(index));
try { try {
await ref.read(dietServiceProvider).deleteRecord(id); await ref.read(dietServiceProvider).deleteRecord(id);
ref.invalidate(dietRecordsProvider);
if (mounted) { if (mounted) {
AppToast.show(context, '已删除', type: AppToastType.success); AppToast.show(context, '已删除', type: AppToastType.success);
} }
@@ -205,6 +206,10 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5), padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5),
child: GestureDetector( child: GestureDetector(
onTap: () => setState(() => _selectedDate = d), onTap: () => setState(() => _selectedDate = d),
child: CustomPaint(
foregroundPainter: cal > 0
? const _DietDateGradientBorderPainter()
: null,
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -212,7 +217,9 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
? AppColors.primary ? AppColors.primary
: (isToday ? DietPalette.primarySoft : Colors.white), : (isToday ? DietPalette.primarySoft : Colors.white),
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
border: Border.all( border: cal > 0
? null
: Border.all(
color: isSel color: isSel
? Colors.transparent ? Colors.transparent
: (isToday : (isToday
@@ -253,7 +260,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: isSel ? Colors.white70 : AppColors.textHint, color: isSel ? Colors.white70 : AppColors.primary,
), ),
), ),
], ],
@@ -262,6 +269,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
), ),
), ),
), ),
),
); );
}), }),
); );
@@ -557,7 +565,7 @@ class _DietTrendPanel extends StatelessWidget {
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textPrimary, color: AppColors.primary,
), ),
), ),
const Spacer(), const Spacer(),
@@ -633,14 +641,15 @@ class _TrendChart extends StatelessWidget {
'${data[i].toInt()}', '${data[i].toInt()}',
style: const TextStyle( style: const TextStyle(
fontSize: 8, fontSize: 8,
color: AppColors.textHint, color: AppColors.primary,
), ),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Container( Container(
height: h, height: h,
decoration: BoxDecoration( 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), 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 { class _SwipeAction extends StatelessWidget {
final Key itemKey; final Key itemKey;
final Widget child; final Widget child;
@@ -709,19 +744,6 @@ class DietRecordDetailPage extends ConsumerStatefulWidget {
} }
class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> { class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> {
late Future<List<Map<String, dynamic>>> _recordsFuture;
@override
void initState() {
super.initState();
_recordsFuture = _loadRecords();
}
Future<List<Map<String, dynamic>>> _loadRecords() =>
ref.read(dietServiceProvider).getRecords();
void _reload() => setState(() => _recordsFuture = _loadRecords());
String _formatRecordedAt(Object? value) { String _formatRecordedAt(Object? value) {
final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal(); final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal();
if (date == null) return ''; if (date == null) return '';
@@ -742,22 +764,12 @@ class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> {
), ),
title: const Text('饮食详情'), title: const Text('饮食详情'),
), ),
body: FutureBuilder<List<Map<String, dynamic>>>( body: AppAsyncValueView<List<Map<String, dynamic>>>(
future: _recordsFuture, value: ref.watch(dietRecordsProvider),
builder: (ctx, snap) { errorTitle: '饮食记录加载失败',
if (!snap.hasData) { onRetry: () => ref.invalidate(dietRecordsProvider),
if (snap.hasError) { onData: (ctx, records) {
return AppErrorState( final d = records.firstWhere(
title: '饮食记录加载失败',
subtitle: '请检查网络后重新进入',
onRetry: _reload,
);
}
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
final d = snap.data!.firstWhere(
(r) => r['id']?.toString() == widget.id, (r) => r['id']?.toString() == widget.id,
orElse: () => <String, dynamic>{}, orElse: () => <String, dynamic>{},
); );
@@ -2370,18 +2382,6 @@ class FollowUpListPage extends ConsumerStatefulWidget {
} }
class _FollowUpListPageState extends ConsumerState<FollowUpListPage> { class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
Future<List<Map<String, dynamic>>>? _future;
@override
void initState() {
super.initState();
_load();
}
void _load() => setState(() {
_future = ref.read(followUpServiceProvider).getList();
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GradientScaffold( return GradientScaffold(
@@ -2393,18 +2393,11 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
title: const Text('复查随访'), title: const Text('复查随访'),
centerTitle: true, centerTitle: true,
), ),
body: FutureBuilder<List<Map<String, dynamic>>>( body: AppAsyncValueView<List<Map<String, dynamic>>>(
future: _future, value: ref.watch(followUpListProvider),
builder: (ctx, snap) { errorTitle: '随访加载失败',
if (snap.connectionState == ConnectionState.waiting) { onRetry: () => ref.invalidate(followUpListProvider),
return const Center( onData: (ctx, list) {
child: CircularProgressIndicator(color: AppTheme.primaryLight),
);
}
if (snap.hasError) {
return AppErrorState(title: '随访加载失败', onRetry: _load);
}
final list = snap.data ?? [];
if (list.isEmpty) { if (list.isEmpty) {
return Center( return Center(
child: Column( child: Column(

View File

@@ -57,12 +57,31 @@ final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
}); });
/// 用药列表 Provider /// 用药列表 Provider
final medicationListProvider = final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async { ref,
) async {
final service = ref.watch(medicationServiceProvider); final service = ref.watch(medicationServiceProvider);
return service.getList(); return service.getList();
}); });
final exercisePlansProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
return ref.watch(exerciseServiceProvider).getPlans();
});
final followUpListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
return ref.watch(followUpServiceProvider).getList();
});
final dietRecordsProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
return ref.watch(dietServiceProvider).getRecords();
});
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>(( final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((
ref, ref,
) async { ) async {

View File

@@ -12,9 +12,16 @@ class AdminDrawer extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final currentPage = ref.watch(adminPageProvider); 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( return DrawerShell(
child: SafeArea( child: SafeArea(
child: CustomScrollView(
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Column( child: Column(
children: [ children: [
Container( Container(
@@ -77,37 +84,69 @@ class AdminDrawer extends ConsumerWidget {
icon: Icons.local_hospital, icon: Icons.local_hospital,
label: '医生管理', label: '医生管理',
selected: currentPage == 'doctors', selected: currentPage == 'doctors',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(adminPageProvider.notifier).set('doctors'); () => pages.set('doctors'),
}, ),
), ),
_MenuItem( _MenuItem(
icon: Icons.people_outline, icon: Icons.people_outline,
label: '患者列表', label: '患者列表',
selected: currentPage == 'patients', selected: currentPage == 'patients',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(adminPageProvider.notifier).set('patients'); () => pages.set('patients'),
}, ),
), ),
const Spacer(), const Spacer(),
const Divider(height: 1, color: AppColors.divider), const Divider(height: 1, color: AppColors.divider),
const SizedBox(height: 8), 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( _MenuItem(
icon: Icons.logout, icon: Icons.logout,
label: '退出登录', label: '退出登录',
danger: true, danger: true,
onTap: () async { onTap: () async {
Navigator.pop(context); final confirmed = await confirmAccountAction(
await ref.read(authProvider.notifier).logout(); context,
goRoute(ref, 'login'); 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: 16),
], ],
), ),
), ),
],
),
),
); );
} }
} }

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart'; import '../core/app_colors.dart';
import 'app_error_state.dart'; import 'app_error_state.dart';
@@ -49,3 +50,35 @@ class AppFutureView<T> extends StatelessWidget {
); );
} }
} }
/// Riverpod 异步数据视图。Provider 会保留最近一次成功数据,刷新时不清空页面。
class AppAsyncValueView<T> extends StatelessWidget {
final AsyncValue<T> 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),
),
);
}

View File

@@ -32,7 +32,7 @@ Future<void> showBleSyncDialog(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppModuleVisuals.device.gradient, color: AppModuleVisuals.device.color,
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
), ),
child: Icon( child: Icon(

View File

@@ -14,65 +14,72 @@ class DoctorDrawer extends ConsumerWidget {
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final currentPage = ref.watch(doctorPageProvider); final currentPage = ref.watch(doctorPageProvider);
final name = auth.user?.name ?? '医生'; 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( return DrawerShell(
child: SafeArea( child: SafeArea(
child: CustomScrollView(
slivers: [
SliverFillRemaining(
hasScrollBody: false,
child: Column( child: Column(
children: [ children: [
_DrawerHeader( _DrawerHeader(
name: name, name: name,
subtitle: '点击完善医生信息', subtitle: '点击完善医生信息',
icon: Icons.medical_services_outlined, icon: Icons.medical_services_outlined,
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
pushRoute(ref, 'doctorProfile'); () => routes.push('doctorProfile'),
}, ),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
_DrawerItem( _DrawerItem(
icon: Icons.dashboard_outlined, icon: Icons.dashboard_outlined,
label: '工作台', label: '工作台',
selected: currentPage == 'dashboard', selected: currentPage == 'dashboard',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(doctorPageProvider.notifier).set('dashboard'); () => pages.set('dashboard'),
}, ),
), ),
_DrawerItem( _DrawerItem(
icon: Icons.people_outline, icon: Icons.people_outline,
label: '患者管理', label: '患者管理',
selected: currentPage == 'patients', selected: currentPage == 'patients',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(doctorPageProvider.notifier).set('patients'); () => pages.set('patients'),
}, ),
), ),
_DrawerItem( _DrawerItem(
icon: Icons.chat_outlined, icon: Icons.chat_outlined,
label: '问诊列表', label: '问诊列表',
selected: currentPage == 'consultations', selected: currentPage == 'consultations',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(doctorPageProvider.notifier).set('consultations'); () => pages.set('consultations'),
}, ),
), ),
_DrawerItem( _DrawerItem(
icon: Icons.description_outlined, icon: Icons.description_outlined,
label: '报告审核', label: '报告审核',
selected: currentPage == 'reports', selected: currentPage == 'reports',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(doctorPageProvider.notifier).set('reports'); () => pages.set('reports'),
}, ),
), ),
_DrawerItem( _DrawerItem(
icon: Icons.event_note_outlined, icon: Icons.event_note_outlined,
label: '复查随访', label: '复查随访',
selected: currentPage == 'followups', selected: currentPage == 'followups',
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
ref.read(doctorPageProvider.notifier).set('followups'); () => pages.set('followups'),
}, ),
), ),
const Spacer(), const Spacer(),
const Divider(height: 1, color: AppColors.divider), const Divider(height: 1, color: AppColors.divider),
@@ -81,10 +88,10 @@ class DoctorDrawer extends ConsumerWidget {
icon: Icons.settings_outlined, icon: Icons.settings_outlined,
label: '设置', label: '设置',
selected: false, selected: false,
onTap: () { onTap: () => runAfterDrawerClose(
Navigator.pop(context); context,
pushRoute(ref, 'doctorSettings'); () => routes.push('doctorSettings'),
}, ),
), ),
_DrawerItem( _DrawerItem(
icon: Icons.logout, icon: Icons.logout,
@@ -92,15 +99,26 @@ class DoctorDrawer extends ConsumerWidget {
selected: false, selected: false,
danger: true, danger: true,
onTap: () async { onTap: () async {
Navigator.pop(context); final confirmed = await confirmAccountAction(
await ref.read(authProvider.notifier).logout(); context,
goRoute(ref, 'login'); 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: 16),
], ],
), ),
), ),
],
),
),
); );
} }
} }

View File

@@ -1,5 +1,44 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
Future<void> runAfterDrawerClose(
BuildContext context,
FutureOr<void> Function() action,
) async {
Navigator.of(context).pop();
await action();
}
Future<bool> confirmAccountAction(
BuildContext context, {
required String title,
required String message,
required String confirmLabel,
}) async {
return await showDialog<bool>(
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 { class DrawerShell extends StatelessWidget {
final double widthFactor; final double widthFactor;
final Widget child; final Widget child;

View File

@@ -213,11 +213,7 @@ class _HealthDashboard extends StatelessWidget {
onTap: () => pushRoute(ref, 'trend'), onTap: () => pushRoute(ref, 'trend'),
), ),
titleColor: Colors.white, titleColor: Colors.white,
backgroundGradient: const LinearGradient( backgroundPainter: const _HealthDashboardBackgroundPainter(),
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
),
child: latestHealth.when( child: latestHealth.when(
data: (data) => _DashboardMetrics(data: data, ref: ref), data: (data) => _DashboardMetrics(data: data, ref: ref),
loading: () => const SizedBox( loading: () => const SizedBox(
@@ -392,7 +388,7 @@ class _NavigationSection extends StatelessWidget {
colors: AppColors.medicationGradient.colors, colors: AppColors.medicationGradient.colors,
), ),
_NavItem( _NavItem(
icon: LucideIcons.utensils, icon: AppModuleVisuals.diet.icon,
title: '饮食', title: '饮食',
route: 'dietRecords', route: 'dietRecords',
colors: AppColors.dietGradient.colors, colors: AppColors.dietGradient.colors,
@@ -476,6 +472,7 @@ class _NavTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDevice = item.route == 'devices';
return InkWell( return InkWell(
onTap: onTap, onTap: onTap,
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
@@ -488,12 +485,16 @@ class _NavTile extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( color: isDevice ? AppColors.device : null,
gradient: isDevice
? null
: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: _colors, colors: _colors,
), ),
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
border: null,
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: _colors.last.withValues(alpha: 0.10), color: _colors.last.withValues(alpha: 0.10),
@@ -567,24 +568,25 @@ class _Panel extends StatelessWidget {
final String title; final String title;
final Widget child; final Widget child;
final Widget? trailing; final Widget? trailing;
final LinearGradient? backgroundGradient; final CustomPainter? backgroundPainter;
final Color titleColor; final Color titleColor;
const _Panel({ const _Panel({
required this.title, required this.title,
required this.child, required this.child,
this.trailing, this.trailing,
this.backgroundGradient, this.backgroundPainter,
this.titleColor = AppColors.textPrimary, this.titleColor = AppColors.textPrimary,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(14), clipBehavior: Clip.antiAlias,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: color: backgroundPainter == null ? null : const Color(0xFFBAE6FD),
backgroundGradient ?? gradient: backgroundPainter != null
LinearGradient( ? null
: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [ colors: [
@@ -595,6 +597,10 @@ class _Panel extends StatelessWidget {
borderRadius: AppRadius.xlBorder, borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.soft, boxShadow: AppShadows.soft,
), ),
child: CustomPaint(
painter: backgroundPainter,
child: Padding(
padding: const EdgeInsets.all(14),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@@ -617,10 +623,41 @@ class _Panel extends StatelessWidget {
child, 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 { class _TextAction extends StatelessWidget {
final String label; final String label;
final VoidCallback onTap; final VoidCallback onTap;

View File

@@ -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,
);
}
}

View File

@@ -89,7 +89,9 @@ void main() {
'lib/pages/auth/login_page.dart', 'lib/pages/auth/login_page.dart',
).readAsStringSync().replaceAll('\r\n', '\n'); ).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('Colors.white.withValues(alpha: 0.88)'));
expect(login, contains('fillColor: AppColors.cardInner')); expect(login, contains('fillColor: AppColors.cardInner'));
expect(login, contains('showDragHandle: true')); expect(login, contains('showDragHandle: true'));
@@ -202,7 +204,10 @@ void main() {
contains('foregroundColor: AppColors.textPrimary'), contains('foregroundColor: AppColors.textPrimary'),
); );
expect(originalReportAction, contains('color: AppColors.border')); 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('width: 68'));
expect(notifications, contains("const TextSpan(text: ' 条未读消息')")); expect(notifications, contains("const TextSpan(text: ' 条未读消息')"));
expect( expect(