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

## 抽屉重构
- admin_drawer / doctor_drawer / health_drawer 三端抽屉全部重做
- drawer_shell 增强

## 配色系统
- app_colors / app_module_visuals / app_theme 更新
- app.dart 启动流程调整

## 后台管理页
- admin_doctors_page 大幅重构(+251)
- admin_home / doctor_dashboard / doctor_reports / doctor_home / doctor_followups / doctor_settings 精调

## 患者端
- home_page / chat_messages_view / medication_list / notification_center / remaining_pages / exercise_plan / device / diet / consultation 微调

## 新增
- keyboard_lift.dart: 键盘抬起处理组件
- AGENTS.md: agent 指引文档

## 其他
- api_client IP 适配
- app_future_view 增强
- data_providers 调整
- secondary_page_visuals_test 更新
This commit is contained in:
MingNian
2026-07-19 19:11:30 +08:00
parent ae94ced2d5
commit 0d4fd88ce7
35 changed files with 1022 additions and 644 deletions

View File

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

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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,

View File

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

View File

@@ -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,

View File

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

View File

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

View File

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

View File

@@ -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(

View File

@@ -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,
),
),
],

View File

@@ -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,

View File

@@ -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,
),
),
),
],
],
),
),
],
),

View File

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

View File

@@ -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'),

View File

@@ -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' => '患者管理',

View File

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

View File

@@ -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() ?? ''},
),
),
);
},
),
);
},
);
},
),
),
],

View File

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

View File

@@ -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) {

View File

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

View File

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

View File

@@ -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) {

View File

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

View File

@@ -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(

View File

@@ -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,

View File

@@ -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),
],
),
),

View File

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

View File

@@ -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(

View File

@@ -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),
],
),
),

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
/// 只让输入区响应键盘高度,避免整棵页面随 MediaQuery 反复重建。
class KeyboardLift extends StatelessWidget {
final Widget child;
const KeyboardLift({super.key, required this.child});
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Padding(
padding: EdgeInsets.only(bottom: bottom),
child: child,
);
}
}