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:
57
AGENTS.md
Normal file
57
AGENTS.md
Normal 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. 执行后进行完整验证并报告结果。
|
||||||
|
|
||||||
|
用户在具体任务中的直接指令始终优先于本文件。
|
||||||
@@ -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);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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' => '患者管理',
|
||||||
|
|||||||
@@ -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: [
|
||||||
// 患者+分类信息
|
// 患者+分类信息
|
||||||
|
|||||||
@@ -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> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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');
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
17
health_app/lib/widgets/keyboard_lift.dart
Normal file
17
health_app/lib/widgets/keyboard_lift.dart
Normal 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(
|
||||||
|
|||||||
Reference in New Issue
Block a user