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:
@@ -74,11 +74,28 @@ class _BootGateState extends ConsumerState<_BootGate> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_startTimeout();
|
||||
}
|
||||
|
||||
void _startTimeout() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer(const Duration(seconds: 8), () {
|
||||
if (mounted) setState(() => _timedOut = true);
|
||||
});
|
||||
}
|
||||
|
||||
void _handleReadyChanged(bool? previous, bool ready) {
|
||||
if (ready) {
|
||||
_timer?.cancel();
|
||||
if (_timedOut) setState(() => _timedOut = false);
|
||||
return;
|
||||
}
|
||||
if (previous != false) {
|
||||
if (_timedOut) setState(() => _timedOut = false);
|
||||
_startTimeout();
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
@@ -87,6 +104,7 @@ class _BootGateState extends ConsumerState<_BootGate> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
ref.listen<bool>(appReadyProvider, _handleReadyChanged);
|
||||
final ready = ref.watch(appReadyProvider) || _timedOut;
|
||||
return Stack(
|
||||
children: [
|
||||
@@ -135,9 +153,21 @@ class _RootNavigator extends ConsumerWidget {
|
||||
}
|
||||
|
||||
return PopScope(
|
||||
canPop: stack.length <= 1,
|
||||
canPop: false,
|
||||
onPopInvokedWithResult: (didPop, result) {
|
||||
if (!didPop) popRoute(ref);
|
||||
if (didPop) return;
|
||||
if (stack.length > 1) {
|
||||
popRoute(ref);
|
||||
return;
|
||||
}
|
||||
final homeRoute = switch (authState.user?.role) {
|
||||
'Admin' => 'adminHome',
|
||||
'Doctor' => 'doctorHome',
|
||||
_ => 'home',
|
||||
};
|
||||
if (current.name != homeRoute && authState.isLoggedIn) {
|
||||
goRoute(ref, homeRoute);
|
||||
}
|
||||
},
|
||||
child: Navigator(
|
||||
pages: [
|
||||
@@ -148,8 +178,12 @@ class _RootNavigator extends ConsumerWidget {
|
||||
child: buildPage(stack[index], ref),
|
||||
),
|
||||
],
|
||||
onDidRemovePage: (_) {
|
||||
if (ref.read(routeStackProvider).length > 1) popRoute(ref);
|
||||
onDidRemovePage: (page) {
|
||||
final routes = ref.read(routeStackProvider);
|
||||
if (routes.length <= 1) return;
|
||||
final lastIndex = routes.length - 1;
|
||||
final currentPageKey = ValueKey(_pageKey(routes.last, lastIndex));
|
||||
if (page.key == currentPageKey) popRoute(ref);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: kReleaseMode
|
||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||
: 'http://10.4.159.130:5000',
|
||||
: 'http://10.4.165.54:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -26,9 +26,9 @@ class AppColors {
|
||||
static const Color medication = Color(0xFF00B8D9);
|
||||
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||
static const Color exercise = Color(0xFF566FD4);
|
||||
static const Color exerciseLight = Color(0xFFF1F3FD);
|
||||
static const Color exerciseBorder = Color(0xFFD7DCF6);
|
||||
static const Color exercise = Color(0xFF7C3AED);
|
||||
static const Color exerciseLight = Color(0xFFF3E8FF);
|
||||
static const Color exerciseBorder = Color(0xFFDDD6FE);
|
||||
static const Color report = Color(0xFF6366F1);
|
||||
static const Color reportLight = Color(0xFFEFFBFF);
|
||||
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||
@@ -168,7 +168,7 @@ class AppColors {
|
||||
static const LinearGradient healthGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [Color(0xFF2FAE9B), Color(0xFF63BFAE)],
|
||||
colors: [Color(0xFF43E97B), Color(0xFF38F9D7)],
|
||||
);
|
||||
|
||||
static const LinearGradient medicationGradient = LinearGradient(
|
||||
@@ -180,7 +180,7 @@ class AppColors {
|
||||
static const LinearGradient exerciseGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF5B7CDE), Color(0xFF7569D9)],
|
||||
colors: [Color(0xFFC4B5FD), Color(0xFF7C3AED)],
|
||||
);
|
||||
|
||||
static const LinearGradient reportGradient = LinearGradient(
|
||||
|
||||
@@ -95,7 +95,7 @@ class AppModuleVisuals {
|
||||
static const diet = AppModuleVisual(
|
||||
module: AppModule.diet,
|
||||
label: '饮食',
|
||||
icon: LucideIcons.utensils,
|
||||
icon: LucideIcons.salad,
|
||||
color: AppColors.diet,
|
||||
lightColor: AppColors.dietLight,
|
||||
borderColor: AppColors.dietBorder,
|
||||
|
||||
@@ -548,6 +548,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
final Widget? drawer;
|
||||
final Widget? bottomNavigationBar;
|
||||
final bool extendBody;
|
||||
final bool resizeToAvoidBottomInset;
|
||||
|
||||
const GradientScaffold({
|
||||
super.key,
|
||||
@@ -557,6 +558,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
this.drawer,
|
||||
this.bottomNavigationBar,
|
||||
this.extendBody = false,
|
||||
this.resizeToAvoidBottomInset = true,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -572,6 +574,7 @@ class GradientScaffold extends StatelessWidget {
|
||||
drawer: drawer,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
extendBody: extendBody,
|
||||
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -121,6 +121,7 @@ class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||
),
|
||||
body: BackofficeSurface(
|
||||
child: SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -97,141 +97,144 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
ref.listen(adminDoctorsRefreshSignalProvider, (previous, next) {
|
||||
if (previous != null && previous != next) _load();
|
||||
});
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.pageGrey,
|
||||
floatingActionButton: FloatingActionButton(
|
||||
backgroundColor: AppColors.primary,
|
||||
onPressed: () => pushRoute(ref, 'adminAddDoctor'),
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
body: _loading
|
||||
? const BackofficeLoadingState(message: '正在加载医生')
|
||||
: _error != null
|
||||
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||
: _doctors.isEmpty
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.medical_services_outlined,
|
||||
title: '暂无医生',
|
||||
description: '点击右下角按钮添加第一位医生',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _doctors.length,
|
||||
itemBuilder: (_, i) {
|
||||
final d = _doctors[i];
|
||||
final active = d['isActive'] != false;
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: active
|
||||
? AppColors.successLight
|
||||
: AppColors.background,
|
||||
child: Icon(
|
||||
Icons.local_hospital,
|
||||
size: 20,
|
||||
color: active
|
||||
? AppColors.success
|
||||
: AppColors.textHint,
|
||||
),
|
||||
final body = _loading
|
||||
? const BackofficeLoadingState(message: '正在加载医生')
|
||||
: _error != null
|
||||
? BackofficeErrorState(message: _error!, onRetry: _load)
|
||||
: _doctors.isEmpty
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.medical_services_outlined,
|
||||
title: '暂无医生',
|
||||
description: '点击右下角按钮添加第一位医生',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _doctors.length,
|
||||
itemBuilder: (_, i) {
|
||||
final d = _doctors[i];
|
||||
final active = d['isActive'] != false;
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: active
|
||||
? AppColors.successLight
|
||||
: AppColors.background,
|
||||
child: Icon(
|
||||
Icons.local_hospital,
|
||||
size: 20,
|
||||
color: active ? AppColors.success : AppColors.textHint,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
d['name'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (!active)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已停用',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${d['title'] ?? ''} · ${d['department'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (d['professionalDirection'] != null)
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
d['professionalDirection']!,
|
||||
d['name'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') {
|
||||
pushRoute(
|
||||
ref,
|
||||
'adminAddDoctor',
|
||||
params: {
|
||||
'id': d['id']?.toString() ?? '',
|
||||
'phone': d['phone']?.toString() ?? '',
|
||||
'name': d['name']?.toString() ?? '',
|
||||
'title': d['title']?.toString() ?? '',
|
||||
'department': d['department']?.toString() ?? '',
|
||||
'direction':
|
||||
d['professionalDirection']?.toString() ??
|
||||
'',
|
||||
},
|
||||
);
|
||||
}
|
||||
if (v == 'toggle') _toggleActive(d['id']);
|
||||
if (v == 'delete') _delete(d['id']);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
PopupMenuItem(
|
||||
value: 'toggle',
|
||||
child: Text(active ? '停用' : '启用'),
|
||||
const SizedBox(width: 8),
|
||||
if (!active)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: const Text(
|
||||
'已停用',
|
||||
style: TextStyle(
|
||||
fontSize: 10,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${d['title'] ?? ''} · ${d['department'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
if (d['professionalDirection'] != null)
|
||||
Text(
|
||||
d['professionalDirection']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'edit') {
|
||||
pushRoute(
|
||||
ref,
|
||||
'adminAddDoctor',
|
||||
params: {
|
||||
'id': d['id']?.toString() ?? '',
|
||||
'phone': d['phone']?.toString() ?? '',
|
||||
'name': d['name']?.toString() ?? '',
|
||||
'title': d['title']?.toString() ?? '',
|
||||
'department': d['department']?.toString() ?? '',
|
||||
'direction':
|
||||
d['professionalDirection']?.toString() ?? '',
|
||||
},
|
||||
);
|
||||
}
|
||||
if (v == 'toggle') _toggleActive(d['id']);
|
||||
if (v == 'delete') _delete(d['id']);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 'edit', child: Text('编辑')),
|
||||
PopupMenuItem(
|
||||
value: 'toggle',
|
||||
child: Text(active ? '停用' : '启用'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned.fill(child: body),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: FloatingActionButton(
|
||||
backgroundColor: AppColors.primary,
|
||||
onPressed: () => pushRoute(ref, 'adminAddDoctor'),
|
||||
child: const Icon(Icons.add, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,31 +16,48 @@ class AdminPageNotifier extends Notifier<String> {
|
||||
void set(String page) => state = page;
|
||||
}
|
||||
|
||||
class AdminHomePage extends ConsumerWidget {
|
||||
class AdminHomePage extends ConsumerStatefulWidget {
|
||||
const AdminHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final page = ref.watch(adminPageProvider);
|
||||
ConsumerState<AdminHomePage> createState() => _AdminHomePageState();
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.pageGrey,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
page == 'patients' ? '患者管理' : '医生管理',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
class _AdminHomePageState extends ConsumerState<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,
|
||||
drawerEnableOpenDragGesture: true,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(
|
||||
page == 'patients' ? '患者管理' : '医生管理',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
drawer: const AdminDrawer(),
|
||||
body: BackofficeSurface(
|
||||
child: IndexedStack(
|
||||
index: pageIndex,
|
||||
children: _pages
|
||||
.map((page) => page ?? const SizedBox.shrink())
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
drawer: const AdminDrawer(),
|
||||
body: BackofficeSurface(
|
||||
child: switch (page) {
|
||||
'doctors' => const AdminDoctorsPage(),
|
||||
'patients' => const AdminPatientsPage(),
|
||||
_ => const AdminDoctorsPage(),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../providers/consultation_provider.dart';
|
||||
import '../../widgets/keyboard_lift.dart';
|
||||
|
||||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||
class DoctorChatPage extends ConsumerStatefulWidget {
|
||||
@@ -62,6 +63,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
final canSend = state.status == 'AiTalking' && !state.isSending;
|
||||
|
||||
return GradientScaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
title: Column(
|
||||
@@ -113,7 +115,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(child: _buildMessageList(state)),
|
||||
_buildInputBar(canSend),
|
||||
KeyboardLift(child: _buildInputBar(canSend)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -425,9 +425,9 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
onPressed: () => pushRoute(ref, 'deviceScan'),
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.primaryDark,
|
||||
foregroundColor: AppColors.device,
|
||||
fixedSize: const Size(40, 40),
|
||||
side: const BorderSide(color: AppColors.primaryLight),
|
||||
side: const BorderSide(color: AppColors.deviceBorder),
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
icon: const Icon(Icons.add_rounded),
|
||||
@@ -604,10 +604,10 @@ class _DevicesHeader extends StatelessWidget {
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: _deviceVisual.lightColor,
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(_deviceVisual.icon, color: AppColors.device, size: 27),
|
||||
child: Icon(_deviceVisual.icon, color: Colors.white, size: 27),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
@@ -671,14 +671,10 @@ class _DeviceSection extends StatelessWidget {
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: _deviceVisual.lightColor,
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(
|
||||
type.icon,
|
||||
size: 19,
|
||||
color: _deviceVisual.color,
|
||||
),
|
||||
child: Icon(type.icon, size: 19, color: Colors.white),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
@@ -733,7 +729,6 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final accent = connected ? AppColors.successText : AppColors.textHint;
|
||||
final availability = deviceSyncAvailabilityLabel(device.type);
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
@@ -750,10 +745,10 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: connected ? AppColors.successLight : AppColors.deviceLight,
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(device.type.icon, color: accent, size: 22),
|
||||
child: Icon(device.type.icon, color: Colors.white, size: 22),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
|
||||
@@ -445,12 +445,12 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.deviceLight,
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(
|
||||
type?.icon ?? Icons.bluetooth_rounded,
|
||||
color: AppColors.device,
|
||||
color: Colors.white,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
@@ -565,14 +565,13 @@ class _ScanPulse extends StatelessWidget {
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: AppColors.device,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_searching_rounded,
|
||||
size: 32,
|
||||
color: AppColors.device,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -378,6 +378,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
final state = ref.watch(dietProvider);
|
||||
|
||||
return SingleChildScrollView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
|
||||
@@ -229,25 +229,30 @@ class _StatCard extends StatelessWidget {
|
||||
child: Icon(icon, color: color, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -78,6 +78,7 @@ class _DoctorFollowUpEditPageState
|
||||
),
|
||||
body: BackofficeSurface(
|
||||
child: ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
|
||||
@@ -62,22 +62,22 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
children: [
|
||||
_Filt('全部', _filter == '', () => setState(() => _filter = '')),
|
||||
const SizedBox(width: 8),
|
||||
_Filt(
|
||||
'待完成',
|
||||
_filter == 'Upcoming',
|
||||
() => setState(() => _filter = 'Upcoming'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Filt(
|
||||
'已完成',
|
||||
_filter == 'Completed',
|
||||
() => setState(() => _filter = 'Completed'),
|
||||
),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: AppColors.primary),
|
||||
onPressed: () => pushRoute(ref, 'doctorFollowUpEdit'),
|
||||
|
||||
@@ -9,12 +9,41 @@ import 'doctor_consultations_page.dart';
|
||||
import 'doctor_reports_page.dart';
|
||||
import 'doctor_followups_page.dart';
|
||||
|
||||
class DoctorHomePage extends ConsumerWidget {
|
||||
class DoctorHomePage extends ConsumerStatefulWidget {
|
||||
const DoctorHomePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<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 pageIndex = switch (page) {
|
||||
'patients' => 1,
|
||||
'consultations' => 2,
|
||||
'reports' => 3,
|
||||
'followups' => 4,
|
||||
_ => 0,
|
||||
};
|
||||
_pages[pageIndex] ??= _createPage(pageIndex);
|
||||
|
||||
return PopScope(
|
||||
canPop: page == 'dashboard',
|
||||
@@ -39,20 +68,19 @@ class DoctorHomePage extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
drawer: const DoctorDrawer(),
|
||||
body: BackofficeSurface(child: _bodyFor(page)),
|
||||
drawerEnableOpenDragGesture: true,
|
||||
body: BackofficeSurface(
|
||||
child: IndexedStack(
|
||||
index: pageIndex,
|
||||
children: _pages
|
||||
.map((page) => page ?? const SizedBox.shrink())
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _bodyFor(String page) => switch (page) {
|
||||
'dashboard' => const DoctorDashboardPage(),
|
||||
'patients' => const DoctorPatientsPage(),
|
||||
'consultations' => const DoctorConsultationsPage(),
|
||||
'reports' => const DoctorReportsPage(),
|
||||
'followups' => const DoctorFollowupsPage(),
|
||||
_ => const DoctorDashboardPage(),
|
||||
};
|
||||
|
||||
String _titleFor(String page) => switch (page) {
|
||||
'dashboard' => '工作台',
|
||||
'patients' => '患者管理',
|
||||
|
||||
@@ -132,6 +132,7 @@ class _DoctorReportDetailPageState
|
||||
final fileUrl = data['fileUrl'] as String?;
|
||||
|
||||
return ListView(
|
||||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 患者+分类信息
|
||||
|
||||
@@ -6,20 +6,13 @@ import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
|
||||
final _reportsProvider =
|
||||
FutureProvider.family<List<Map<String, dynamic>>, String>((
|
||||
ref,
|
||||
status,
|
||||
) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final params = <String, dynamic>{};
|
||||
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>>() ?? [];
|
||||
});
|
||||
final _reportsProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/reports');
|
||||
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
});
|
||||
|
||||
class DoctorReportsPage extends ConsumerStatefulWidget {
|
||||
const DoctorReportsPage({super.key});
|
||||
@@ -32,12 +25,14 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reports = ref.watch(_reportsProvider(_filter));
|
||||
final reports = ref.watch(_reportsProvider);
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FilterChip(
|
||||
'全部',
|
||||
@@ -45,14 +40,12 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
_filter,
|
||||
() => setState(() => _filter = ''),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
'待审核',
|
||||
'PendingDoctor',
|
||||
_filter,
|
||||
() => setState(() => _filter = 'PendingDoctor'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
'已审核',
|
||||
'DoctorReviewed',
|
||||
@@ -66,57 +59,66 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
child: reports.when(
|
||||
loading: () => const BackofficeLoadingState(message: '正在加载报告'),
|
||||
error: (_, _) => BackofficeErrorState(
|
||||
onRetry: () => ref.invalidate(_reportsProvider(_filter)),
|
||||
onRetry: () => ref.invalidate(_reportsProvider),
|
||||
),
|
||||
data: (items) => items.isEmpty
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
title: '暂无报告',
|
||||
description: '患者上传的待审核报告会显示在这里',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = items[i];
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
data: (allItems) {
|
||||
final items = _filter.isEmpty
|
||||
? allItems
|
||||
: allItems
|
||||
.where((item) => item['status'] == _filter)
|
||||
.toList();
|
||||
return items.isEmpty
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
title: '暂无报告',
|
||||
description: '患者上传的待审核报告会显示在这里',
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = items[i];
|
||||
return BackofficeSectionCard(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
color: AppColors.primary,
|
||||
title: Text(
|
||||
r['patientName'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${r['category'] ?? '未分类'} · '
|
||||
'${formatBackofficeDateTime(r['createdAt'])}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorReportDetail',
|
||||
params: {'id': r['id']?.toString() ?? ''},
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
r['patientName'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${r['category'] ?? '未分类'} · '
|
||||
'${formatBackofficeDateTime(r['createdAt'])}',
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorReportDetail',
|
||||
params: {'id': r['id']?.toString() ?? ''},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/backoffice_ui.dart';
|
||||
import '../../widgets/drawer_shell.dart';
|
||||
|
||||
class DoctorSettingsPage extends ConsumerWidget {
|
||||
const DoctorSettingsPage({super.key});
|
||||
@@ -56,6 +57,13 @@ class DoctorSettingsPage extends ConsumerWidget {
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final confirmed = await confirmAccountAction(
|
||||
context,
|
||||
title: '退出登录',
|
||||
message: '确定退出当前医生账号吗?',
|
||||
confirmLabel: '退出登录',
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
|
||||
@@ -24,24 +24,10 @@ class EnterpriseExercisePlanPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _EnterpriseExercisePlanPageState
|
||||
extends ConsumerState<EnterpriseExercisePlanPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _busyItems = {};
|
||||
final Set<String> _deletingPlans = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() => setState(() {
|
||||
_future = ref.read(exerciseServiceProvider).getPlans();
|
||||
});
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_load();
|
||||
await _future;
|
||||
}
|
||||
Future<void> _refresh() => ref.refresh(exercisePlansProvider.future);
|
||||
|
||||
Future<void> _toggleCheckIn(String itemId) async {
|
||||
if (itemId.isEmpty || _busyItems.contains(itemId)) return;
|
||||
@@ -49,7 +35,7 @@ class _EnterpriseExercisePlanPageState
|
||||
try {
|
||||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
_load();
|
||||
ref.invalidate(exercisePlansProvider);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
@@ -88,7 +74,7 @@ class _EnterpriseExercisePlanPageState
|
||||
try {
|
||||
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
_load();
|
||||
ref.invalidate(exercisePlansProvider);
|
||||
if (mounted) {
|
||||
AppToast.show(context, '运动计划已删除', type: AppToastType.success);
|
||||
}
|
||||
@@ -119,9 +105,9 @@ class _EnterpriseExercisePlanPageState
|
||||
tooltip: '新建运动计划',
|
||||
onPressed: () => pushRoute(ref, 'exerciseCreate'),
|
||||
),
|
||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
onRetry: _load,
|
||||
body: AppAsyncValueView<List<Map<String, dynamic>>>(
|
||||
value: ref.watch(exercisePlansProvider),
|
||||
onRetry: () => ref.invalidate(exercisePlansProvider),
|
||||
errorTitle: '运动计划加载失败',
|
||||
onData: (_, plans) {
|
||||
if (plans.isEmpty) {
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
import '../../widgets/keyboard_lift.dart';
|
||||
import '../diet/diet_capture_page.dart';
|
||||
import 'widgets/chat_messages_view.dart';
|
||||
|
||||
@@ -151,6 +152,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
|
||||
return Scaffold(
|
||||
key: _scaffoldKey,
|
||||
resizeToAvoidBottomInset: false,
|
||||
backgroundColor: const Color(0xFFFFFCFF),
|
||||
drawer: const HealthDrawer(),
|
||||
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,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Icon(visual.icon, size: 13, color: Colors.white),
|
||||
child: Icon(
|
||||
visual.icon,
|
||||
size: 13,
|
||||
color: visual.iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
@@ -321,9 +327,8 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
);
|
||||
}
|
||||
|
||||
({String label, IconData icon, LinearGradient gradient}) _agentVisual(
|
||||
ActiveAgent agent,
|
||||
) {
|
||||
({String label, IconData icon, LinearGradient gradient, Color iconColor})
|
||||
_agentVisual(ActiveAgent agent) {
|
||||
({String label, AppModuleVisual visual}) fromModule(
|
||||
String label,
|
||||
AppModuleVisual visual,
|
||||
@@ -342,6 +347,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
label: module.label,
|
||||
icon: module.visual.icon,
|
||||
gradient: module.visual.gradient,
|
||||
iconColor: Colors.white,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -350,11 +356,13 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
label: 'AI问诊',
|
||||
icon: LucideIcons.messageCircle,
|
||||
gradient: AppColors.doctorGradient,
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
_ => (
|
||||
label: 'AI问诊',
|
||||
icon: LucideIcons.messageCircle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1287,11 +1287,20 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
border: visual.borderColor,
|
||||
iconBg: visual.lightColor,
|
||||
accent: visual.color,
|
||||
iconColor: Colors.white,
|
||||
verticalGradient: true,
|
||||
);
|
||||
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => fromModule(AppModuleVisuals.health),
|
||||
ActiveAgent.health => _AgentColors(
|
||||
gradient: AppModuleVisuals.health.gradient.colors,
|
||||
bg: AppModuleVisuals.health.lightColor,
|
||||
border: AppModuleVisuals.health.borderColor,
|
||||
iconBg: AppModuleVisuals.health.lightColor,
|
||||
accent: AppModuleVisuals.health.color,
|
||||
iconColor: Colors.white,
|
||||
verticalGradient: true,
|
||||
),
|
||||
ActiveAgent.diet => fromModule(AppModuleVisuals.diet),
|
||||
ActiveAgent.medication => fromModule(AppModuleVisuals.medication),
|
||||
ActiveAgent.report => fromModule(AppModuleVisuals.report),
|
||||
@@ -1302,6 +1311,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
border: const Color(0xFFFFDFE7),
|
||||
iconBg: const Color(0xFFFFEEF2),
|
||||
accent: const Color(0xFFFF7F98),
|
||||
iconColor: Colors.white,
|
||||
verticalGradient: true,
|
||||
),
|
||||
_ => _AgentColors(
|
||||
@@ -1310,6 +1320,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
border: const Color(0xFFE7E0FF),
|
||||
iconBg: const Color(0xFFF0EDFF),
|
||||
accent: AppColors.primary,
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1317,7 +1328,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
static (_AgentIcon, String, String) _agentInfo(ActiveAgent agent) {
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => (LucideIcons.heartPulse, '记数据', '录入血压、血糖、心率等日常指标'),
|
||||
ActiveAgent.diet => (LucideIcons.utensils, '拍饮食', '拍照识别食物热量和营养成分'),
|
||||
ActiveAgent.diet => (AppModuleVisuals.diet.icon, '拍饮食', '拍照识别食物热量和营养成分'),
|
||||
ActiveAgent.medication => (LucideIcons.pill, '药管家', '管理药品、提醒服药、追踪用量'),
|
||||
ActiveAgent.consultation => (
|
||||
LucideIcons.messageCircle,
|
||||
@@ -1967,7 +1978,7 @@ class _AgentMark extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Center(child: Icon(icon, size: 32, color: Colors.white)),
|
||||
child: Center(child: Icon(icon, size: 32, color: colors.iconColor)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -2027,6 +2038,7 @@ class _AgentColors {
|
||||
final Color border;
|
||||
final Color iconBg;
|
||||
final Color accent;
|
||||
final Color iconColor;
|
||||
final bool verticalGradient;
|
||||
const _AgentColors({
|
||||
required this.gradient,
|
||||
@@ -2034,6 +2046,7 @@ class _AgentColors {
|
||||
required this.border,
|
||||
required this.iconBg,
|
||||
required this.accent,
|
||||
required this.iconColor,
|
||||
this.verticalGradient = false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -24,23 +24,9 @@ class MedicationListPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _deleting = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() => setState(() {
|
||||
_future = ref.read(medicationServiceProvider).getMedications('');
|
||||
});
|
||||
|
||||
Future<void> _refresh() async {
|
||||
_load();
|
||||
await _future;
|
||||
}
|
||||
Future<void> _refresh() => ref.refresh(medicationListProvider.future);
|
||||
|
||||
Future<void> _confirmDelete(String id, String name) async {
|
||||
if (id.isEmpty || _deleting.contains(id)) return;
|
||||
@@ -68,7 +54,6 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
await ref.read(medicationServiceProvider).deleteMedication(id);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
_load();
|
||||
if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
@@ -97,9 +82,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
tooltip: '添加用药',
|
||||
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||
),
|
||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
onRetry: _load,
|
||||
body: AppAsyncValueView<List<Map<String, dynamic>>>(
|
||||
value: ref.watch(medicationListProvider),
|
||||
onRetry: () => ref.invalidate(medicationListProvider),
|
||||
errorTitle: '用药信息加载失败',
|
||||
onData: (_, medications) {
|
||||
if (medications.isEmpty) {
|
||||
|
||||
@@ -521,10 +521,19 @@ class _NotificationRow extends StatelessWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
color: visual.lightIconSurface
|
||||
? AppColors.device
|
||||
: null,
|
||||
gradient: visual.lightIconSurface
|
||||
? null
|
||||
: visual.gradient,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(visual.icon, size: 21, color: visual.color),
|
||||
child: Icon(
|
||||
visual.icon,
|
||||
size: 21,
|
||||
color: visual.iconColor,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 11),
|
||||
Expanded(
|
||||
@@ -657,46 +666,66 @@ class _NotificationVisual {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final Color lightColor;
|
||||
final Gradient gradient;
|
||||
final Color iconColor;
|
||||
final bool lightIconSurface;
|
||||
final String label;
|
||||
|
||||
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
||||
const _NotificationVisual(
|
||||
this.icon,
|
||||
this.color,
|
||||
this.lightColor,
|
||||
this.gradient,
|
||||
this.iconColor,
|
||||
this.lightIconSurface,
|
||||
this.label,
|
||||
);
|
||||
|
||||
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
|
||||
factory _NotificationVisual.fromModule(
|
||||
AppModuleVisual visual, {
|
||||
Color iconColor = Colors.white,
|
||||
bool lightIconSurface = false,
|
||||
}) {
|
||||
return _NotificationVisual(
|
||||
visual.icon,
|
||||
visual.color,
|
||||
visual.lightColor,
|
||||
visual.gradient,
|
||||
iconColor,
|
||||
lightIconSurface,
|
||||
visual.label,
|
||||
);
|
||||
}
|
||||
|
||||
factory _NotificationVisual.of(InAppNotification item) {
|
||||
final kind = item.actionType ?? item.type;
|
||||
final kind = (item.actionType ?? item.type).toLowerCase();
|
||||
switch (kind) {
|
||||
case 'exercise':
|
||||
case 'exercisereminder':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
|
||||
case 'health':
|
||||
return item.severity == 'critical'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
AppColors.errorText,
|
||||
AppColors.errorLight,
|
||||
'紧急',
|
||||
)
|
||||
: _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||
case 'health_record_reminder':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||
case 'report':
|
||||
return item.severity == 'error'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
AppColors.errorText,
|
||||
AppColors.errorLight,
|
||||
'报告',
|
||||
)
|
||||
: _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||
case 'medication':
|
||||
case 'medicationreminder':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.medication);
|
||||
case 'diet':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.diet);
|
||||
case 'followup':
|
||||
case 'follow_up_reminder':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.followup);
|
||||
case 'doctor':
|
||||
case 'consultation':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.doctor);
|
||||
case 'device':
|
||||
case 'bluetooth':
|
||||
return _NotificationVisual.fromModule(
|
||||
AppModuleVisuals.device,
|
||||
iconColor: Colors.white,
|
||||
lightIconSurface: true,
|
||||
);
|
||||
default:
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.notification);
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
setState(() => _data.removeAt(index));
|
||||
try {
|
||||
await ref.read(dietServiceProvider).deleteRecord(id);
|
||||
ref.invalidate(dietRecordsProvider);
|
||||
if (mounted) {
|
||||
AppToast.show(context, '已删除', type: AppToastType.success);
|
||||
}
|
||||
@@ -205,59 +206,66 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5),
|
||||
child: GestureDetector(
|
||||
onTap: () => setState(() => _selectedDate = d),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSel
|
||||
? AppColors.primary
|
||||
: (isToday ? DietPalette.primarySoft : Colors.white),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
child: CustomPaint(
|
||||
foregroundPainter: cal > 0
|
||||
? const _DietDateGradientBorderPainter()
|
||||
: null,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: isSel
|
||||
? Colors.transparent
|
||||
: (isToday
|
||||
? const Color(0xFFC9D0FF)
|
||||
: AppColors.borderLight),
|
||||
? AppColors.primary
|
||||
: (isToday ? DietPalette.primarySoft : Colors.white),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: cal > 0
|
||||
? null
|
||||
: Border.all(
|
||||
color: isSel
|
||||
? Colors.transparent
|
||||
: (isToday
|
||||
? const Color(0xFFC9D0FF)
|
||||
: AppColors.borderLight),
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
isToday
|
||||
? '今天'
|
||||
: ['一', '二', '三', '四', '五', '六', '日'][d.weekday -
|
||||
1],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSel ? Colors.white : AppColors.textHint,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
isToday
|
||||
? '今天'
|
||||
: ['一', '二', '三', '四', '五', '六', '日'][d.weekday -
|
||||
1],
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSel ? Colors.white : AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'${d.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSel ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (cal > 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
'$cal',
|
||||
'${d.day}',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSel ? Colors.white70 : AppColors.textHint,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isSel ? Colors.white : AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (cal > 0) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$cal',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isSel ? Colors.white70 : AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -557,7 +565,7 @@ class _DietTrendPanel extends StatelessWidget {
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -633,14 +641,15 @@ class _TrendChart extends StatelessWidget {
|
||||
'${data[i].toInt()}',
|
||||
style: const TextStyle(
|
||||
fontSize: 8,
|
||||
color: AppColors.textHint,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Container(
|
||||
height: h,
|
||||
decoration: BoxDecoration(
|
||||
color: data[i] > 0 ? DietPalette.primary : AppColors.border,
|
||||
color: data[i] > 0 ? null : AppColors.border,
|
||||
gradient: data[i] > 0 ? AppColors.primaryGradient : null,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
@@ -653,6 +662,32 @@ class _TrendChart extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _DietDateGradientBorderPainter extends CustomPainter {
|
||||
const _DietDateGradientBorderPainter();
|
||||
|
||||
static const _strokeWidth = 1.5;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = (Offset.zero & size).deflate(_strokeWidth / 2);
|
||||
final paint = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = _strokeWidth
|
||||
..shader = AppColors.primaryGradient.createShader(rect)
|
||||
..isAntiAlias = true;
|
||||
canvas.drawRRect(
|
||||
RRect.fromRectAndRadius(
|
||||
rect,
|
||||
const Radius.circular(AppTheme.rMd - _strokeWidth / 2),
|
||||
),
|
||||
paint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_DietDateGradientBorderPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _SwipeAction extends StatelessWidget {
|
||||
final Key itemKey;
|
||||
final Widget child;
|
||||
@@ -709,19 +744,6 @@ class DietRecordDetailPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _DietRecordDetailPageState extends ConsumerState<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) {
|
||||
final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal();
|
||||
if (date == null) return '';
|
||||
@@ -742,22 +764,12 @@ class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> {
|
||||
),
|
||||
title: const Text('饮食详情'),
|
||||
),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _recordsFuture,
|
||||
builder: (ctx, snap) {
|
||||
if (!snap.hasData) {
|
||||
if (snap.hasError) {
|
||||
return AppErrorState(
|
||||
title: '饮食记录加载失败',
|
||||
subtitle: '请检查网络后重新进入',
|
||||
onRetry: _reload,
|
||||
);
|
||||
}
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primary),
|
||||
);
|
||||
}
|
||||
final d = snap.data!.firstWhere(
|
||||
body: AppAsyncValueView<List<Map<String, dynamic>>>(
|
||||
value: ref.watch(dietRecordsProvider),
|
||||
errorTitle: '饮食记录加载失败',
|
||||
onRetry: () => ref.invalidate(dietRecordsProvider),
|
||||
onData: (ctx, records) {
|
||||
final d = records.firstWhere(
|
||||
(r) => r['id']?.toString() == widget.id,
|
||||
orElse: () => <String, dynamic>{},
|
||||
);
|
||||
@@ -2370,18 +2382,6 @@ class FollowUpListPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
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
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
@@ -2393,18 +2393,11 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
title: const Text('复查随访'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryLight),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return AppErrorState(title: '随访加载失败', onRetry: _load);
|
||||
}
|
||||
final list = snap.data ?? [];
|
||||
body: AppAsyncValueView<List<Map<String, dynamic>>>(
|
||||
value: ref.watch(followUpListProvider),
|
||||
errorTitle: '随访加载失败',
|
||||
onRetry: () => ref.invalidate(followUpListProvider),
|
||||
onData: (ctx, list) {
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
|
||||
@@ -57,11 +57,30 @@ final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
});
|
||||
|
||||
/// 用药列表 Provider
|
||||
final medicationListProvider =
|
||||
FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getList();
|
||||
});
|
||||
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
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>>>((
|
||||
ref,
|
||||
|
||||
@@ -12,99 +12,138 @@ class AdminDrawer extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(adminPageProvider);
|
||||
final pages = ref.read(adminPageProvider.notifier);
|
||||
final routes = ref.read(routeStackProvider.notifier);
|
||||
final authNotifier = ref.read(authProvider.notifier);
|
||||
|
||||
return DrawerShell(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.88),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.86),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
margin: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
color: Colors.white.withValues(alpha: 0.88),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.86),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'系统管理员',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
Container(
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'医生与患者管理',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'系统管理员',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 3),
|
||||
Text(
|
||||
'医生与患者管理',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MenuItem(
|
||||
icon: Icons.local_hospital,
|
||||
label: '医生管理',
|
||||
selected: currentPage == 'doctors',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('doctors'),
|
||||
),
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.people_outline,
|
||||
label: '患者列表',
|
||||
selected: currentPage == 'patients',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('patients'),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 1, color: AppColors.divider),
|
||||
const SizedBox(height: 8),
|
||||
_MenuItem(
|
||||
icon: Icons.delete_forever_outlined,
|
||||
label: '删除账号',
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
final confirmed = await confirmAccountAction(
|
||||
context,
|
||||
title: '删除账号',
|
||||
message: '此操作会永久删除当前管理员账号及相关数据,删除后无法恢复。',
|
||||
confirmLabel: '确认删除',
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
await runAfterDrawerClose(context, () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.delete('/api/user/account');
|
||||
await authNotifier.logout();
|
||||
routes.replace('login');
|
||||
});
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
final confirmed = await confirmAccountAction(
|
||||
context,
|
||||
title: '退出登录',
|
||||
message: '确定退出当前管理员账号吗?',
|
||||
confirmLabel: '退出登录',
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
await runAfterDrawerClose(context, () async {
|
||||
await authNotifier.logout();
|
||||
routes.replace('login');
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_MenuItem(
|
||||
icon: Icons.local_hospital,
|
||||
label: '医生管理',
|
||||
selected: currentPage == 'doctors',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(adminPageProvider.notifier).set('doctors');
|
||||
},
|
||||
),
|
||||
_MenuItem(
|
||||
icon: Icons.people_outline,
|
||||
label: '患者列表',
|
||||
selected: currentPage == 'patients',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(adminPageProvider.notifier).set('patients');
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 1, color: AppColors.divider),
|
||||
const SizedBox(height: 8),
|
||||
_MenuItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import 'app_error_state.dart';
|
||||
|
||||
@@ -49,3 +50,35 @@ class AppFutureView<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,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppModuleVisuals.device.gradient,
|
||||
color: AppModuleVisuals.device.color,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(
|
||||
|
||||
@@ -14,90 +14,108 @@ class DoctorDrawer extends ConsumerWidget {
|
||||
final auth = ref.watch(authProvider);
|
||||
final currentPage = ref.watch(doctorPageProvider);
|
||||
final name = auth.user?.name ?? '医生';
|
||||
final pages = ref.read(doctorPageProvider.notifier);
|
||||
final routes = ref.read(routeStackProvider.notifier);
|
||||
final authNotifier = ref.read(authProvider.notifier);
|
||||
|
||||
return DrawerShell(
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
_DrawerHeader(
|
||||
name: name,
|
||||
subtitle: '点击完善医生信息',
|
||||
icon: Icons.medical_services_outlined,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
pushRoute(ref, 'doctorProfile');
|
||||
},
|
||||
child: CustomScrollView(
|
||||
slivers: [
|
||||
SliverFillRemaining(
|
||||
hasScrollBody: false,
|
||||
child: Column(
|
||||
children: [
|
||||
_DrawerHeader(
|
||||
name: name,
|
||||
subtitle: '点击完善医生信息',
|
||||
icon: Icons.medical_services_outlined,
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => routes.push('doctorProfile'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DrawerItem(
|
||||
icon: Icons.dashboard_outlined,
|
||||
label: '工作台',
|
||||
selected: currentPage == 'dashboard',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('dashboard'),
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.people_outline,
|
||||
label: '患者管理',
|
||||
selected: currentPage == 'patients',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('patients'),
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.chat_outlined,
|
||||
label: '问诊列表',
|
||||
selected: currentPage == 'consultations',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('consultations'),
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.description_outlined,
|
||||
label: '报告审核',
|
||||
selected: currentPage == 'reports',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('reports'),
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.event_note_outlined,
|
||||
label: '复查随访',
|
||||
selected: currentPage == 'followups',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => pages.set('followups'),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 1, color: AppColors.divider),
|
||||
const SizedBox(height: 8),
|
||||
_DrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '设置',
|
||||
selected: false,
|
||||
onTap: () => runAfterDrawerClose(
|
||||
context,
|
||||
() => routes.push('doctorSettings'),
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
selected: false,
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
final confirmed = await confirmAccountAction(
|
||||
context,
|
||||
title: '退出登录',
|
||||
message: '确定退出当前医生账号吗?',
|
||||
confirmLabel: '退出登录',
|
||||
);
|
||||
if (!confirmed || !context.mounted) return;
|
||||
await runAfterDrawerClose(context, () async {
|
||||
await authNotifier.logout();
|
||||
routes.replace('login');
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_DrawerItem(
|
||||
icon: Icons.dashboard_outlined,
|
||||
label: '工作台',
|
||||
selected: currentPage == 'dashboard',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(doctorPageProvider.notifier).set('dashboard');
|
||||
},
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.people_outline,
|
||||
label: '患者管理',
|
||||
selected: currentPage == 'patients',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(doctorPageProvider.notifier).set('patients');
|
||||
},
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.chat_outlined,
|
||||
label: '问诊列表',
|
||||
selected: currentPage == 'consultations',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||
},
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.description_outlined,
|
||||
label: '报告审核',
|
||||
selected: currentPage == 'reports',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
},
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.event_note_outlined,
|
||||
label: '复查随访',
|
||||
selected: currentPage == 'followups',
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
},
|
||||
),
|
||||
const Spacer(),
|
||||
const Divider(height: 1, color: AppColors.divider),
|
||||
const SizedBox(height: 8),
|
||||
_DrawerItem(
|
||||
icon: Icons.settings_outlined,
|
||||
label: '设置',
|
||||
selected: false,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
pushRoute(ref, 'doctorSettings');
|
||||
},
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.logout,
|
||||
label: '退出登录',
|
||||
selected: false,
|
||||
danger: true,
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,5 +1,44 @@
|
||||
import 'dart:async';
|
||||
|
||||
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 {
|
||||
final double widthFactor;
|
||||
final Widget child;
|
||||
|
||||
@@ -213,11 +213,7 @@ class _HealthDashboard extends StatelessWidget {
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
),
|
||||
titleColor: Colors.white,
|
||||
backgroundGradient: const LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
|
||||
),
|
||||
backgroundPainter: const _HealthDashboardBackgroundPainter(),
|
||||
child: latestHealth.when(
|
||||
data: (data) => _DashboardMetrics(data: data, ref: ref),
|
||||
loading: () => const SizedBox(
|
||||
@@ -392,7 +388,7 @@ class _NavigationSection extends StatelessWidget {
|
||||
colors: AppColors.medicationGradient.colors,
|
||||
),
|
||||
_NavItem(
|
||||
icon: LucideIcons.utensils,
|
||||
icon: AppModuleVisuals.diet.icon,
|
||||
title: '饮食',
|
||||
route: 'dietRecords',
|
||||
colors: AppColors.dietGradient.colors,
|
||||
@@ -476,6 +472,7 @@ class _NavTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isDevice = item.route == 'devices';
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
@@ -488,12 +485,16 @@ class _NavTile extends StatelessWidget {
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: _colors,
|
||||
),
|
||||
color: isDevice ? AppColors.device : null,
|
||||
gradient: isDevice
|
||||
? null
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: _colors,
|
||||
),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: null,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: _colors.last.withValues(alpha: 0.10),
|
||||
@@ -567,60 +568,96 @@ class _Panel extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? trailing;
|
||||
final LinearGradient? backgroundGradient;
|
||||
final CustomPainter? backgroundPainter;
|
||||
final Color titleColor;
|
||||
const _Panel({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
this.backgroundGradient,
|
||||
this.backgroundPainter,
|
||||
this.titleColor = AppColors.textPrimary,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
gradient:
|
||||
backgroundGradient ??
|
||||
LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.92),
|
||||
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
|
||||
],
|
||||
),
|
||||
color: backgroundPainter == null ? null : const Color(0xFFBAE6FD),
|
||||
gradient: backgroundPainter != null
|
||||
? null
|
||||
: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.92),
|
||||
const Color(0xFFF8F4FF).withValues(alpha: 0.84),
|
||||
],
|
||||
),
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppShadows.soft,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
child: CustomPaint(
|
||||
painter: backgroundPainter,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: titleColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
?trailing,
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
child,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HealthDashboardBackgroundPainter extends CustomPainter {
|
||||
const _HealthDashboardBackgroundPainter();
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final rect = Offset.zero & size;
|
||||
canvas.drawRect(rect, Paint()..color = const Color(0xFFBAE6FD));
|
||||
|
||||
void drawGlow(Alignment center, Color color) {
|
||||
final paint = Paint()
|
||||
..shader = RadialGradient(
|
||||
center: center,
|
||||
radius: 1,
|
||||
colors: [color, color.withValues(alpha: 0)],
|
||||
stops: const [0, 0.8],
|
||||
).createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
drawGlow(Alignment.bottomRight, const Color(0xFF818CF8));
|
||||
drawGlow(const Alignment(0, 0.6), const Color(0xFF60A5FA));
|
||||
drawGlow(const Alignment(0.6, -0.6), const Color(0xFF6366F1));
|
||||
drawGlow(const Alignment(-0.6, -0.6), const Color(0xFF38BDF8));
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _TextAction extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user