fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者 - 注册页重构:去掉角色选择+审核码,改为选医生+填姓名 - 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立 - Doctor/DoctorProfile/User实体新增关联字段 - JSON循环引用修复(IgnoreCycles) - /api/doctors改为公开接口 - 登录闪屏修复+原生Android启动页 - 输入框全局白底+灰框 - 蓝牙重连同步修复 - Web端(doctor_web+health_app/web)全部删除 - 全局UI改版:白底企业风,新配色和组件系统 - 新品牌图标和启动图
This commit is contained in:
BIN
health_app/assets/branding/agent_welcome_abstract.png
Normal file
BIN
health_app/assets/branding/agent_welcome_abstract.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'core/app_colors.dart';
|
||||
import 'core/app_router.dart';
|
||||
import 'core/app_theme.dart';
|
||||
import 'core/navigation_provider.dart';
|
||||
@@ -23,17 +22,12 @@ class HealthApp extends ConsumerWidget {
|
||||
GlobalWidgetsLocalizations.delegate,
|
||||
GlobalCupertinoLocalizations.delegate,
|
||||
],
|
||||
supportedLocales: const [
|
||||
Locale('zh', 'CN'),
|
||||
Locale('zh'),
|
||||
],
|
||||
supportedLocales: const [Locale('zh', 'CN'), Locale('zh')],
|
||||
locale: const Locale('zh'),
|
||||
home: const _RootNavigator(),
|
||||
// 注入 ShadTheme,让所有页面都能用 shadcn 组件
|
||||
builder: (context, child) => ShadTheme(
|
||||
data: AppTheme.shadTheme,
|
||||
child: child!,
|
||||
),
|
||||
builder: (context, child) =>
|
||||
ShadTheme(data: AppTheme.shadTheme, child: child!),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,24 +42,17 @@ class _RootNavigator extends ConsumerWidget {
|
||||
final current = stack.last;
|
||||
final authState = ref.watch(authProvider);
|
||||
|
||||
// 启动时正在检查登录状态 → 显示闪屏
|
||||
if (authState.isLoading && current.name == 'login') {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.favorite, size: 48, color: AppColors.primary),
|
||||
const SizedBox(height: 16),
|
||||
Text('健康管家', style: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 登录后根据角色分流
|
||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||
if (authState.isLoggedIn && current.name == 'login') {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
goRoute(ref, authState.user?.role == 'Doctor' ? 'doctorHome' : 'home');
|
||||
final role = authState.user?.role ?? 'User';
|
||||
if (role == 'Admin') {
|
||||
goRoute(ref, 'adminHome');
|
||||
} else if (role == 'Doctor') {
|
||||
goRoute(ref, 'doctorHome');
|
||||
} else {
|
||||
goRoute(ref, 'home');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,83 +1,94 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 健康管家 — 色彩体系 v3.0
|
||||
/// Enterprise health app color system.
|
||||
class AppColors {
|
||||
AppColors._();
|
||||
|
||||
// ── 主色 ──
|
||||
static const Color primary = Color(0xFF8B5CF6);
|
||||
static const Color primaryLight = Color(0xFFA78BFA);
|
||||
static const Color primaryDark = Color(0xFF7C3AED);
|
||||
static const Color primary = Color(0xFF7C5CFF);
|
||||
static const Color primaryLight = Color(0xFFEDE8FF);
|
||||
static const Color primarySoft = Color(0xFFF6F3FF);
|
||||
static const Color primaryDark = Color(0xFF5B3FE8);
|
||||
static const Color blueMeasure = Color(0xFF4F7CFF);
|
||||
static const Color doctorBlue = Color(0xFF3B82F6);
|
||||
|
||||
static const Color background = Color(0xFFF7F8FC);
|
||||
static const Color backgroundSoft = Color(0xFFFBFCFF);
|
||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||
static const Color cardInner = Color(0xFFF8FAFF);
|
||||
static const Color pageGrey = background;
|
||||
static const Color iconBg = Color(0xFFF0F4FF);
|
||||
static const Color avatarBg = Color(0xFFF3F0FF);
|
||||
|
||||
static const Color textPrimary = Color(0xFF1F2937);
|
||||
static const Color textSecondary = Color(0xFF667085);
|
||||
static const Color textHint = Color(0xFF98A2B3);
|
||||
static const Color textOnGradient = Color(0xFFFFFFFF);
|
||||
|
||||
static const Color border = Color(0xFFE5E7EB);
|
||||
static const Color borderLight = Color(0xFFF1F3F8);
|
||||
static const Color divider = Color(0xFFEFF2F7);
|
||||
|
||||
static const Color success = Color(0xFF16A34A);
|
||||
static const Color successLight = Color(0xFFEAF8EF);
|
||||
static const Color error = Color(0xFFEF4444);
|
||||
static const Color errorLight = Color(0xFFFEECEF);
|
||||
static const Color warning = Color(0xFFF59E0B);
|
||||
static const Color warningLight = Color(0xFFFFF7E6);
|
||||
static const Color infoLight = Color(0xFFEFF5FF);
|
||||
|
||||
static const LinearGradient primaryGradient = LinearGradient(
|
||||
begin: Alignment.topCenter, end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF7C5CFF), Color(0xFF4F7CFF)],
|
||||
);
|
||||
|
||||
// ── 医生端主色 ──
|
||||
static const Color doctorBlue = Color(0xFF6366F1);
|
||||
static const LinearGradient doctorGradient = LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [
|
||||
Color(0xFF38BDF8), Color(0xFF6366F1), Color(0xFF60A5FA), Color(0xFF818CF8)
|
||||
]);
|
||||
|
||||
// ── 背景 ──
|
||||
static const Color background = Color(0xFFF0ECFF);
|
||||
static const Color cardBackground = Color(0xFFFFFFFF);
|
||||
static const Color cardInner = Color(0xFFF5F2FF);
|
||||
static const Color pageGrey = Color(0xFFF5F5F5); // 医生端/设置页灰底
|
||||
static const Color iconBg = Color(0xFFE8E0FA);
|
||||
|
||||
static const LinearGradient bgGradient = LinearGradient(
|
||||
begin: Alignment.centerLeft, end: Alignment.centerRight,
|
||||
colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)],
|
||||
stops: [0.0, 0.4],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFF7F8FC), Color(0xFFF3F0FF)],
|
||||
stops: [0.0, 0.68, 1.0],
|
||||
);
|
||||
|
||||
// 抽屉专用渐变(原色降低饱和度)
|
||||
static const LinearGradient drawerGradient = LinearGradient(
|
||||
begin: Alignment.topLeft, end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFD4B5F0), Color(0xFFF3E8FF), Color(0xFFA8A0E8)],
|
||||
stops: [0.0, 0.5, 1.0],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFFFFFFF), Color(0xFFF6F3FF)],
|
||||
);
|
||||
|
||||
// ── 文字 ──
|
||||
static const Color textPrimary = Color(0xFF1F2937);
|
||||
static const Color textSecondary = Color(0xFF6B7280);
|
||||
static const Color textHint = Color(0xFF9CA3AF);
|
||||
static const LinearGradient doctorGradient = LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF4F7CFF), Color(0xFF7C5CFF)],
|
||||
);
|
||||
|
||||
// ── 边框 ──
|
||||
static const Color border = Color(0xFFE5E7EB);
|
||||
static const Color borderLight = Color(0xFFF3F4F6);
|
||||
|
||||
// ── 功能色 ──
|
||||
static const Color success = Color(0xFF10B981);
|
||||
static const Color successLight = Color(0xFFD1FAE5);
|
||||
static const Color error = Color(0xFFEF4444);
|
||||
static const Color errorLight = Color(0xFFFEE2E2);
|
||||
static const Color warning = Color(0xFFF59E0B);
|
||||
static const Color warningLight = Color(0xFFFEF3C7);
|
||||
static const Color blueMeasure = Color(0xFF3B82F6);
|
||||
static const Color avatarBg = Color(0xFFF0F0FF); // 头像底
|
||||
|
||||
// ── 阴影 ──
|
||||
static List<BoxShadow> get cardShadow => [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2)),
|
||||
];
|
||||
static List<BoxShadow> get cardShadowLight => [
|
||||
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1)),
|
||||
];
|
||||
static List<BoxShadow> get buttonShadow => [
|
||||
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 4)),
|
||||
];
|
||||
static List<BoxShadow> get fabShadow => [
|
||||
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)),
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
];
|
||||
|
||||
// ── 兼容别名 ──
|
||||
static Color get iconColor => Color(0xFF9B8AF7);
|
||||
static List<BoxShadow> get cardShadowLight => [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get buttonShadow => [
|
||||
BoxShadow(
|
||||
color: primary.withValues(alpha: 0.22),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> get fabShadow => buttonShadow;
|
||||
|
||||
static Color get iconColor => primary;
|
||||
static LinearGradient get purpleBlueGradient => primaryGradient;
|
||||
static const Color backgroundSoft = background;
|
||||
static const Color textOnGradient = Color(0xFFFFFFFF);
|
||||
static Color get primaryPurple => primary;
|
||||
static Color get primaryBlue => blueMeasure;
|
||||
static Color get backgroundSecondary => background;
|
||||
|
||||
@@ -24,6 +24,8 @@ import '../pages/doctor/doctor_report_detail_page.dart';
|
||||
import '../pages/doctor/doctor_followup_edit_page.dart';
|
||||
import '../pages/doctor/doctor_settings_page.dart';
|
||||
import '../pages/doctor/doctor_profile_page.dart';
|
||||
import '../pages/admin/admin_home_page.dart';
|
||||
import '../pages/admin/admin_add_doctor_page.dart';
|
||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||
|
||||
/// 根据路由信息返回对应页面
|
||||
@@ -35,6 +37,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
final role = ref.watch(userRoleProvider);
|
||||
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
||||
case 'doctorHome': return const DoctorHomePage();
|
||||
case 'adminHome': return const AdminHomePage();
|
||||
case 'adminAddDoctor': return const AdminAddDoctorPage();
|
||||
case 'trend': return TrendPage(metricType: params['type']?.isNotEmpty == true ? params['type'] : null);
|
||||
case 'calendar': return const HealthCalendarPage();
|
||||
case 'medications': return const MedicationListPage();
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import 'app_colors.dart';
|
||||
|
||||
/// 渐变背景 Scaffold(紫→蓝 4:6 渐变)
|
||||
class GradientScaffold extends StatelessWidget {
|
||||
final PreferredSizeWidget? appBar;
|
||||
final Widget? body;
|
||||
@@ -10,9 +9,19 @@ class GradientScaffold extends StatelessWidget {
|
||||
final Widget? drawer;
|
||||
final Widget? bottomNavigationBar;
|
||||
final bool extendBody;
|
||||
const GradientScaffold({super.key, this.appBar, this.body, this.floatingActionButton, this.drawer, this.bottomNavigationBar, this.extendBody = false});
|
||||
|
||||
@override Widget build(BuildContext context) => Container(
|
||||
const GradientScaffold({
|
||||
super.key,
|
||||
this.appBar,
|
||||
this.body,
|
||||
this.floatingActionButton,
|
||||
this.drawer,
|
||||
this.bottomNavigationBar,
|
||||
this.extendBody = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
@@ -26,30 +35,26 @@ class GradientScaffold extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
/// 健康管家 — 设计规范 v1.0:紫色系 + 干净白灰 + 12px圆角
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
// ── 主色调 ──
|
||||
static const Color primary = AppColors.primary;
|
||||
static const Color primaryLight = AppColors.primaryLight;
|
||||
static const Color primaryDark = AppColors.primaryDark;
|
||||
static const Color primaryMid = AppColors.primaryLight;
|
||||
static const Color primaryMid = AppColors.blueMeasure;
|
||||
static const Color accent = AppColors.success;
|
||||
static const Color info = AppColors.blueMeasure;
|
||||
|
||||
// ── 中性色 ──
|
||||
static const Color bg = AppColors.background;
|
||||
static const Color bgSoft = AppColors.background;
|
||||
static const Color bgSoft = AppColors.backgroundSoft;
|
||||
static const Color surface = AppColors.cardBackground;
|
||||
static const Color surfaceInner = AppColors.cardInner;
|
||||
static const Color text = AppColors.textPrimary;
|
||||
static const Color textSub = AppColors.textSecondary;
|
||||
static const Color textHint = AppColors.textHint;
|
||||
static const Color border = AppColors.border;
|
||||
static const Color divider = AppColors.borderLight;
|
||||
static const Color divider = AppColors.divider;
|
||||
|
||||
// ── 语义色 ──
|
||||
static const Color success = AppColors.success;
|
||||
static const Color successLight = AppColors.successLight;
|
||||
static const Color error = AppColors.error;
|
||||
@@ -57,7 +62,6 @@ class AppTheme {
|
||||
static const Color warning = AppColors.warning;
|
||||
static const Color warningLight = AppColors.warningLight;
|
||||
|
||||
// ── 圆角(规范:12px 卡片)──
|
||||
static const double rXs = 4;
|
||||
static const double rSm = 8;
|
||||
static const double rMd = 12;
|
||||
@@ -65,7 +69,6 @@ class AppTheme {
|
||||
static const double rXl = 20;
|
||||
static const double rPill = 999;
|
||||
|
||||
// ── 间距(8px 网格)──
|
||||
static const double sXs = 4;
|
||||
static const double sSm = 8;
|
||||
static const double sMd = 12;
|
||||
@@ -73,144 +76,181 @@ class AppTheme {
|
||||
static const double sXl = 20;
|
||||
static const double sXxl = 24;
|
||||
|
||||
// ── 阴影(轻阴影)──
|
||||
static BoxShadow get shadowCard => BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 8,
|
||||
offset: const Offset(0, 2),
|
||||
);
|
||||
static BoxShadow get shadowLight => BoxShadow(
|
||||
color: Colors.black.withOpacity(0.04),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 1),
|
||||
);
|
||||
static BoxShadow get shadowElevated => BoxShadow(
|
||||
color: primary.withOpacity(0.08),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
);
|
||||
static BoxShadow get shadowButton => BoxShadow(
|
||||
color: primary.withOpacity(0.25),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
);
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.07),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
);
|
||||
|
||||
static BoxShadow get shadowLight => BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.045),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
);
|
||||
|
||||
static BoxShadow get shadowElevated => BoxShadow(
|
||||
color: primary.withValues(alpha: 0.12),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
);
|
||||
|
||||
static BoxShadow get shadowButton => BoxShadow(
|
||||
color: primary.withValues(alpha: 0.22),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 8),
|
||||
);
|
||||
|
||||
// ── 智能体配色 ──
|
||||
static const Map<String, Color> agentColors = {
|
||||
'default': Color(0xFFEDE9FE),
|
||||
'consultation': Color(0xFFE0E7FF),
|
||||
'health': Color(0xFFD1FAE5),
|
||||
'diet': Color(0xFFFEF3C7),
|
||||
'medication': Color(0xFFFEE2E2),
|
||||
'report': Color(0xFFDBEAFE),
|
||||
'exercise': Color(0xFFEDE9FE),
|
||||
'default': Color(0xFFF3F0FF),
|
||||
'consultation': Color(0xFFEFF5FF),
|
||||
'health': Color(0xFFF3F0FF),
|
||||
'diet': Color(0xFFFFF7E6),
|
||||
'medication': Color(0xFFEFF5FF),
|
||||
'report': Color(0xFFF6F3FF),
|
||||
'exercise': Color(0xFFEFF8FF),
|
||||
};
|
||||
|
||||
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
||||
|
||||
// ── Material 主题 ──
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
primary: primary,
|
||||
primaryContainer: primaryLight,
|
||||
onPrimary: Colors.white,
|
||||
secondary: accent,
|
||||
surface: surface,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: bg,
|
||||
splashColor: Colors.transparent,
|
||||
highlightColor: Colors.transparent,
|
||||
hoverColor: primaryLight.withOpacity(0.3),
|
||||
fontFamily: null,
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primary,
|
||||
primary: primary,
|
||||
primaryContainer: primaryLight,
|
||||
onPrimary: Colors.white,
|
||||
secondary: AppColors.blueMeasure,
|
||||
surface: surface,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: bg,
|
||||
splashColor: primary.withValues(alpha: 0.06),
|
||||
highlightColor: Colors.transparent,
|
||||
hoverColor: primaryLight.withValues(alpha: 0.55),
|
||||
fontFamily: null,
|
||||
|
||||
appBarTheme: AppBarTheme(
|
||||
backgroundColor: surface,
|
||||
foregroundColor: text,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
||||
toolbarHeight: 44,
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: surface,
|
||||
foregroundColor: text,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
scrolledUnderElevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
titleTextStyle: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
toolbarHeight: 48,
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(
|
||||
color: surface,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: surface,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(rLg),
|
||||
side: const BorderSide(color: border),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: bgSoft,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: primary, width: 1),
|
||||
),
|
||||
hintStyle: TextStyle(color: textHint, fontSize: 15),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 13),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rMd),
|
||||
borderSide: const BorderSide(color: border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rMd),
|
||||
borderSide: const BorderSide(color: border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rMd),
|
||||
borderSide: const BorderSide(color: primary, width: 1.2),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(rMd),
|
||||
borderSide: const BorderSide(color: error),
|
||||
),
|
||||
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
|
||||
elevation: 0,
|
||||
),
|
||||
),
|
||||
|
||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg))),
|
||||
outlinedButtonTheme: OutlinedButtonThemeData(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: text,
|
||||
side: const BorderSide(color: border),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
|
||||
textStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
||||
titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
||||
titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: text),
|
||||
bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
|
||||
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
|
||||
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||
labelSmall: TextStyle(fontSize: 12, color: textHint),
|
||||
),
|
||||
);
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)),
|
||||
),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: text,
|
||||
),
|
||||
titleLarge: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: text,
|
||||
),
|
||||
titleMedium: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: text,
|
||||
),
|
||||
bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
|
||||
bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
|
||||
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||
labelSmall: TextStyle(fontSize: 12, color: textHint),
|
||||
),
|
||||
);
|
||||
|
||||
// ── Shadcn UI 主题 ──
|
||||
static ShadThemeData get shadTheme => ShadThemeData(
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ShadVioletColorScheme.light(
|
||||
background: bg,
|
||||
foreground: text,
|
||||
card: surface,
|
||||
cardForeground: text,
|
||||
popover: surface,
|
||||
popoverForeground: text,
|
||||
primary: primary,
|
||||
primaryForeground: Colors.white,
|
||||
secondary: primaryLight,
|
||||
secondaryForeground: primaryDark,
|
||||
muted: bgSoft,
|
||||
mutedForeground: textSub,
|
||||
accent: accent,
|
||||
accentForeground: Colors.white,
|
||||
destructive: error,
|
||||
destructiveForeground: Colors.white,
|
||||
border: border,
|
||||
input: border,
|
||||
ring: primary,
|
||||
selection: primaryLight,
|
||||
),
|
||||
radius: BorderRadius.circular(rMd),
|
||||
);
|
||||
brightness: Brightness.light,
|
||||
colorScheme: ShadVioletColorScheme.light(
|
||||
background: bg,
|
||||
foreground: text,
|
||||
card: surface,
|
||||
cardForeground: text,
|
||||
popover: surface,
|
||||
popoverForeground: text,
|
||||
primary: primary,
|
||||
primaryForeground: Colors.white,
|
||||
secondary: primaryLight,
|
||||
secondaryForeground: primaryDark,
|
||||
muted: bgSoft,
|
||||
mutedForeground: textSub,
|
||||
accent: AppColors.blueMeasure,
|
||||
accentForeground: Colors.white,
|
||||
destructive: error,
|
||||
destructiveForeground: Colors.white,
|
||||
border: border,
|
||||
input: border,
|
||||
ring: primary,
|
||||
selection: primaryLight,
|
||||
),
|
||||
radius: BorderRadius.circular(rMd),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart'
|
||||
show databaseFactoryFfiWebNoWebWorker;
|
||||
import 'package:sqflite/sqflite.dart' show databaseFactory;
|
||||
import 'app.dart';
|
||||
|
||||
void main() {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
if (kIsWeb) {
|
||||
databaseFactory = databaseFactoryFfiWebNoWebWorker;
|
||||
}
|
||||
runApp(const ProviderScope(child: HealthApp()));
|
||||
}
|
||||
|
||||
176
health_app/lib/pages/admin/admin_add_doctor_page.dart
Normal file
176
health_app/lib/pages/admin/admin_add_doctor_page.dart
Normal file
@@ -0,0 +1,176 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||
|
||||
class AdminAddDoctorPage extends ConsumerStatefulWidget {
|
||||
const AdminAddDoctorPage({super.key});
|
||||
@override
|
||||
ConsumerState<AdminAddDoctorPage> createState() => _AdminAddDoctorPageState();
|
||||
}
|
||||
|
||||
class _AdminAddDoctorPageState extends ConsumerState<AdminAddDoctorPage> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _deptCtrl = TextEditingController();
|
||||
final _directionCtrl = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_nameCtrl.dispose();
|
||||
_titleCtrl.dispose();
|
||||
_deptCtrl.dispose();
|
||||
_directionCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_phoneCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('手机号不能为空')));
|
||||
return;
|
||||
}
|
||||
if (_nameCtrl.text.trim().isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('姓名不能为空')));
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(adminServiceProvider).addDoctor({
|
||||
'phone': _phoneCtrl.text.trim(),
|
||||
'name': _nameCtrl.text.trim(),
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'department': _deptCtrl.text.trim(),
|
||||
'professionalDirection': _directionCtrl.text.trim(),
|
||||
});
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('添加成功'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
popRoute(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('添加失败: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
'新增医生',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_buildField('手机号 *', _phoneCtrl, TextInputType.phone),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('姓名 *', _nameCtrl, TextInputType.name),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('职称', _titleCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('科室', _deptCtrl, TextInputType.text),
|
||||
const SizedBox(height: 16),
|
||||
_buildField('专业方向', _directionCtrl, TextInputType.text),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
height: 50,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text(
|
||||
'保存',
|
||||
style: TextStyle(fontSize: 16, color: Colors.white),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildField(
|
||||
String label,
|
||||
TextEditingController ctrl,
|
||||
TextInputType type,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
keyboardType: type,
|
||||
decoration: InputDecoration(
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 13,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
200
health_app/lib/pages/admin/admin_doctors_page.dart
Normal file
200
health_app/lib/pages/admin/admin_doctors_page.dart
Normal file
@@ -0,0 +1,200 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||
|
||||
class AdminDoctorsPage extends ConsumerStatefulWidget {
|
||||
const AdminDoctorsPage({super.key});
|
||||
@override
|
||||
ConsumerState<AdminDoctorsPage> createState() => _AdminDoctorsPageState();
|
||||
}
|
||||
|
||||
class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
|
||||
List<Map<String, dynamic>> _doctors = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final res = await ref.read(adminServiceProvider).getDoctors();
|
||||
if (res['code'] == 0 && mounted) {
|
||||
setState(() {
|
||||
_doctors = List<Map<String, dynamic>>.from(res['data'] ?? []);
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _toggleActive(String id) async {
|
||||
try {
|
||||
await ref.read(adminServiceProvider).toggleDoctorActive(id);
|
||||
_load();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('确认删除'),
|
||||
content: const Text('删除后患者关联将被清除,确定吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('删除'),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
try {
|
||||
await ref.read(adminServiceProvider).deleteDoctor(id);
|
||||
_load();
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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 Center(child: CircularProgressIndicator())
|
||||
: _doctors.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无医生',
|
||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _doctors.length,
|
||||
itemBuilder: (_, i) {
|
||||
final d = _doctors[i];
|
||||
final active = d['isActive'] != false;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: Padding(
|
||||
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)
|
||||
Text(
|
||||
d['professionalDirection']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuButton<String>(
|
||||
onSelected: (v) {
|
||||
if (v == 'toggle') _toggleActive(d['id']);
|
||||
if (v == 'delete') _delete(d['id']);
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'toggle',
|
||||
child: Text(active ? '停用' : '启用'),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Text(
|
||||
'删除',
|
||||
style: TextStyle(color: AppColors.error),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
37
health_app/lib/pages/admin/admin_home_page.dart
Normal file
37
health_app/lib/pages/admin/admin_home_page.dart
Normal file
@@ -0,0 +1,37 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../widgets/admin_drawer.dart';
|
||||
import 'admin_doctors_page.dart';
|
||||
import 'admin_patients_page.dart';
|
||||
|
||||
final adminPageProvider = NotifierProvider<AdminPageNotifier, String>(AdminPageNotifier.new);
|
||||
|
||||
class AdminPageNotifier extends Notifier<String> {
|
||||
@override String build() => 'doctors';
|
||||
void set(String page) => state = page;
|
||||
}
|
||||
|
||||
class AdminHomePage extends ConsumerWidget {
|
||||
const AdminHomePage({super.key});
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final page = ref.watch(adminPageProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.pageGrey,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: const Text('系统管理', style: TextStyle(color: AppColors.textPrimary)),
|
||||
iconTheme: const IconThemeData(color: AppColors.textPrimary),
|
||||
),
|
||||
drawer: const AdminDrawer(),
|
||||
body: switch (page) {
|
||||
'doctors' => const AdminDoctorsPage(),
|
||||
'patients' => const AdminPatientsPage(),
|
||||
_ => const AdminDoctorsPage(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
208
health_app/lib/pages/admin/admin_patients_page.dart
Normal file
208
health_app/lib/pages/admin/admin_patients_page.dart
Normal file
@@ -0,0 +1,208 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../providers/data_providers.dart' show adminServiceProvider;
|
||||
|
||||
class AdminPatientsPage extends ConsumerStatefulWidget {
|
||||
const AdminPatientsPage({super.key});
|
||||
@override
|
||||
ConsumerState<AdminPatientsPage> createState() => _AdminPatientsPageState();
|
||||
}
|
||||
|
||||
class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
List<Map<String, dynamic>> _patients = [];
|
||||
int _total = 0;
|
||||
int _page = 1;
|
||||
bool _loading = true;
|
||||
bool _loadingMore = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load({bool reset = true}) async {
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_patients = [];
|
||||
});
|
||||
}
|
||||
try {
|
||||
final res = await ref
|
||||
.read(adminServiceProvider)
|
||||
.getPatients(search: _searchCtrl.text.trim(), page: _page);
|
||||
if (res['code'] == 0 && mounted) {
|
||||
final data = res['data'] as Map<String, dynamic>? ?? {};
|
||||
setState(() {
|
||||
_total = data['total'] ?? 0;
|
||||
final list = List<Map<String, dynamic>>.from(data['patients'] ?? []);
|
||||
if (reset) {
|
||||
_patients = list;
|
||||
} else {
|
||||
_patients.addAll(list);
|
||||
}
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
});
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted)
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_loadingMore = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _loadMore() {
|
||||
if (!_loadingMore && _patients.length < _total) {
|
||||
setState(() {
|
||||
_page++;
|
||||
_loadingMore = true;
|
||||
});
|
||||
_load(reset: false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
// 搜索栏
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索患者姓名或手机号',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
||||
suffixIcon: _searchCtrl.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchCtrl.clear();
|
||||
_load();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _load(),
|
||||
onChanged: (v) => setState(() {}),
|
||||
),
|
||||
),
|
||||
|
||||
// 患者列表
|
||||
Expanded(
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _patients.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无患者',
|
||||
style: TextStyle(color: AppColors.textHint, fontSize: 16),
|
||||
),
|
||||
)
|
||||
: NotificationListener<ScrollNotification>(
|
||||
onNotification: (n) {
|
||||
if (n is ScrollEndNotification &&
|
||||
n.metrics.pixels >= n.metrics.maxScrollExtent - 50)
|
||||
_loadMore();
|
||||
return false;
|
||||
},
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _patients.length + (_loadingMore ? 1 : 0),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= _patients.length)
|
||||
return const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
);
|
||||
final p = _patients[i];
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: CircleAvatar(
|
||||
backgroundColor: AppColors.iconBg,
|
||||
child: Text(
|
||||
p['name']?.toString().isNotEmpty == true
|
||||
? p['name']!.toString()[0]
|
||||
: '?',
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
p['name'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.w500),
|
||||
),
|
||||
subtitle: Text(
|
||||
p['phone'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: p['doctorName'] != null
|
||||
? Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
p['doctorName']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.success,
|
||||
),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,19 +5,18 @@ import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
const _doctorBlue = Color(0xFF0891B2);
|
||||
|
||||
class LoginPage extends ConsumerStatefulWidget {
|
||||
const LoginPage({super.key});
|
||||
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
@override
|
||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
final _phoneCtrl = TextEditingController();
|
||||
final _codeCtrl = TextEditingController();
|
||||
final _inviteCtrl = TextEditingController();
|
||||
String _role = 'User';
|
||||
bool _showInvite = false;
|
||||
final _nameCtrl = TextEditingController();
|
||||
String? _selectedDoctorId;
|
||||
String? _selectedDoctorName;
|
||||
bool _agreed = false;
|
||||
bool _sending = false;
|
||||
int _countdown = 0;
|
||||
@@ -26,47 +25,104 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
bool _isLogin = true;
|
||||
String? _successMsg;
|
||||
|
||||
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); _inviteCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Color get _accent => _role == 'Doctor' ? _doctorBlue : AppColors.primary;
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_codeCtrl.dispose();
|
||||
_nameCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendSms() async {
|
||||
final phone = _phoneCtrl.text.trim();
|
||||
if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
|
||||
setState(() { _sending = true; _error = null; });
|
||||
if (phone.length != 11 || !phone.startsWith('1')) {
|
||||
setState(() => _error = '请输入正确的手机号');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_sending = true;
|
||||
_error = null;
|
||||
});
|
||||
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
||||
setState(() { _sending = false; });
|
||||
if (result.error != null) { setState(() => _error = result.error); return; }
|
||||
setState(() => _sending = false);
|
||||
if (result.error != null) {
|
||||
setState(() => _error = result.error);
|
||||
return;
|
||||
}
|
||||
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||
setState(() => _countdown = 60); _startCountdown();
|
||||
setState(() => _countdown = 60);
|
||||
_startCountdown();
|
||||
}
|
||||
|
||||
void _startCountdown() async {
|
||||
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (mounted) setState(() => _countdown = i - 1); }
|
||||
for (var i = 60; i > 0; i--) {
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
if (mounted) setState(() => _countdown = i - 1);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
|
||||
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) { setState(() => _error = '请输入手机号和验证码'); return; }
|
||||
setState(() { _loading = true; _error = null; _successMsg = null; });
|
||||
if (!_agreed) {
|
||||
setState(() => _error = '请阅读并同意服务协议和隐私政策');
|
||||
return;
|
||||
}
|
||||
if (_phoneCtrl.text.trim().isEmpty || _codeCtrl.text.trim().isEmpty) {
|
||||
setState(() => _error = '请输入手机号和验证码');
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
});
|
||||
|
||||
String? err;
|
||||
if (_isLogin) {
|
||||
err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||
err = await ref
|
||||
.read(authProvider.notifier)
|
||||
.login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||
} else {
|
||||
if (_role == 'Doctor' && _inviteCtrl.text.trim() != '6666') { setState(() { _loading = false; _error = '审核码错误'; }); return; }
|
||||
err = await ref.read(authProvider.notifier).register(_phoneCtrl.text.trim(), _codeCtrl.text.trim(), _role, inviteCode: _role == 'Doctor' ? _inviteCtrl.text.trim() : null);
|
||||
if (_selectedDoctorId == null) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = '请选择医生';
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (_nameCtrl.text.trim().isEmpty) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = '请输入姓名';
|
||||
});
|
||||
return;
|
||||
}
|
||||
err = await ref
|
||||
.read(authProvider.notifier)
|
||||
.register(
|
||||
_phoneCtrl.text.trim(),
|
||||
_codeCtrl.text.trim(),
|
||||
_nameCtrl.text.trim(),
|
||||
_selectedDoctorId!,
|
||||
);
|
||||
}
|
||||
|
||||
setState(() => _loading = false);
|
||||
if (err != null) {
|
||||
if (err.contains('未注册')) { setState(() { _isLogin = false; }); }
|
||||
if (err.contains('未注册')) setState(() => _isLogin = false);
|
||||
setState(() => _error = err);
|
||||
return;
|
||||
}
|
||||
if (!_isLogin) {
|
||||
// 注册成功 → 回登录页
|
||||
setState(() { _isLogin = true; _successMsg = '注册成功,请登录'; _error = null; _phoneCtrl.clear(); _codeCtrl.clear(); _inviteCtrl.clear(); _showInvite = false; });
|
||||
setState(() {
|
||||
_isLogin = true;
|
||||
_successMsg = '注册成功,请登录';
|
||||
_error = null;
|
||||
_phoneCtrl.clear();
|
||||
_codeCtrl.clear();
|
||||
_nameCtrl.clear();
|
||||
_selectedDoctorId = null;
|
||||
_selectedDoctorName = null;
|
||||
});
|
||||
ref.read(authProvider.notifier).logout();
|
||||
} else {
|
||||
_goHome();
|
||||
@@ -79,95 +135,216 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
final role = ref.read(authProvider).user?.role ?? 'User';
|
||||
goRoute(ref, role == 'Doctor' ? 'doctorHome' : 'home');
|
||||
if (role == 'Admin') {
|
||||
goRoute(ref, 'adminHome');
|
||||
} else if (role == 'Doctor') {
|
||||
goRoute(ref, 'doctorHome');
|
||||
} else {
|
||||
goRoute(ref, 'home');
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
void _showDoctorPicker() {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Consumer(
|
||||
builder: (ctx, ref, _) {
|
||||
final doctorsAsync = ref.watch(doctorListProvider);
|
||||
return doctorsAsync.when(
|
||||
data: (doctors) {
|
||||
final active = doctors
|
||||
.where((d) => d['isActive'] != false)
|
||||
.toList();
|
||||
if (active.isEmpty)
|
||||
return const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: Text('暂无可用医生')),
|
||||
);
|
||||
return SizedBox(
|
||||
height: 420,
|
||||
child: Column(
|
||||
children: [
|
||||
const Padding(
|
||||
padding: EdgeInsets.all(18),
|
||||
child: Text(
|
||||
'请选择医生',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Divider(height: 1, color: AppColors.divider),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: active.length,
|
||||
itemBuilder: (_, i) {
|
||||
final d = active[i];
|
||||
final name = d['name'] ?? '';
|
||||
final title = d['title'] ?? '';
|
||||
final dept = d['department'] ?? '';
|
||||
return ListTile(
|
||||
title: Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'$title · $dept',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
trailing: _selectedDoctorId == d['id']?.toString()
|
||||
? const Icon(
|
||||
Icons.check_circle,
|
||||
color: AppColors.primary,
|
||||
)
|
||||
: null,
|
||||
onTap: () {
|
||||
setState(() {
|
||||
_selectedDoctorId = d['id']?.toString();
|
||||
_selectedDoctorName = name;
|
||||
});
|
||||
Navigator.pop(ctx);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
height: 200,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
error: (_, __) =>
|
||||
const SizedBox(height: 200, child: Center(child: Text('加载失败'))),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final authState = ref.watch(authProvider);
|
||||
if (authState.isLoggedIn && !authState.isLoading) {
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _goHome());
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
body: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
const SizedBox(height: 30),
|
||||
Icon(Icons.favorite, size: 48, color: _accent),
|
||||
const SizedBox(height: 12),
|
||||
const Text('健康管家', style: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_isLogin ? '登录你的账号' : '创建新账号', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
if (_successMsg != null)
|
||||
Padding(padding: const EdgeInsets.only(bottom: 16), child: Container(padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: const Color(0xFFD1FAE5), borderRadius: BorderRadius.circular(10)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.check_circle, color: Color(0xFF059669), size: 18), const SizedBox(width: 8), Text(_successMsg!, style: const TextStyle(color: Color(0xFF059669), fontSize: 14))]))),
|
||||
|
||||
// 角色选择(注册模式)
|
||||
if (!_isLogin) ...[
|
||||
Row(children: [
|
||||
Expanded(child: _RoleCard(selected: _role == 'User', icon: Icons.person, label: '用户', desc: '健康管理', color: AppColors.primary, onTap: () => setState(() { _role = 'User'; _showInvite = false; }))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _RoleCard(selected: _role == 'Doctor', icon: Icons.local_hospital, label: '医生', desc: '接诊管理', color: _doctorBlue, onTap: () => setState(() { _role = 'Doctor'; _showInvite = true; }))),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
|
||||
// 手机号
|
||||
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11, _accent),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 验证码
|
||||
Row(children: [
|
||||
Expanded(child: _TextField(_codeCtrl, '验证码', TextInputType.number, 6, _accent)),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(
|
||||
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
||||
child: Container(
|
||||
width: 110, height: 50, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: _countdown > 0 || _sending ? const Color(0xFFF5F5F5) : _accent, borderRadius: BorderRadius.circular(10)),
|
||||
child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: (_countdown > 0 || _sending) ? _accent : Colors.white)),
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SafeArea(
|
||||
child: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 24),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 22),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
]),
|
||||
|
||||
if (_showInvite && !_isLogin) ...[const SizedBox(height: 12), _TextField(_inviteCtrl, '医生审核码', TextInputType.number, 6, _accent)],
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 协议
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _agreed = !_agreed),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 18, height: 18, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(color: _agreed ? _accent : Colors.white, borderRadius: BorderRadius.circular(4), border: Border.all(color: _agreed ? _accent : AppColors.border)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||
RichText(text: TextSpan(children: [const TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: _accent)), const TextSpan(text: '和', style: TextStyle(fontSize: 13, color: AppColors.textHint)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: _accent))])),
|
||||
]),
|
||||
),
|
||||
|
||||
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 提交按钮
|
||||
GestureDetector(
|
||||
onTap: _loading ? null : _submit,
|
||||
child: Container(
|
||||
width: double.infinity, height: 52, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(color: _accent, borderRadius: BorderRadius.circular(12)),
|
||||
child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : Text(_isLogin ? '登 录' : '注 册', style: const TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 4)),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_BrandMark(),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'健康管家',
|
||||
style: TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_isLogin ? '登录你的健康账户' : '创建新的健康账户',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 26),
|
||||
if (_successMsg != null)
|
||||
_NoticeBanner(text: _successMsg!, success: true),
|
||||
if (!_isLogin) ...[
|
||||
_DoctorSelector(
|
||||
name: _selectedDoctorName,
|
||||
onTap: _showDoctorPicker,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_TextField(_nameCtrl, '姓名', TextInputType.name, 20),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
_TextField(_phoneCtrl, '手机号', TextInputType.phone, 11),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TextField(
|
||||
_codeCtrl,
|
||||
'验证码',
|
||||
TextInputType.number,
|
||||
6,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_SmsButton(
|
||||
sending: _sending,
|
||||
countdown: _countdown,
|
||||
onTap: (_countdown > 0 || _sending) ? null : _sendSms,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_Agreement(
|
||||
agreed: _agreed,
|
||||
onTap: () => setState(() => _agreed = !_agreed),
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_NoticeBanner(text: _error!, success: false),
|
||||
],
|
||||
const SizedBox(height: 22),
|
||||
_PrimaryButton(
|
||||
loading: _loading,
|
||||
label: _isLogin ? '登录' : '注册',
|
||||
onTap: _loading ? null : _submit,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() {
|
||||
_isLogin = !_isLogin;
|
||||
_error = null;
|
||||
_successMsg = null;
|
||||
}),
|
||||
child: Text(
|
||||
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() { _isLogin = !_isLogin; _error = null; _successMsg = null; if (_isLogin) _showInvite = false; }),
|
||||
child: Text(_isLogin ? '没有账号?去注册' : '已有账号?去登录', style: TextStyle(fontSize: 14, color: _accent)),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -175,29 +352,269 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
}
|
||||
}
|
||||
|
||||
class _BrandMark extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 68,
|
||||
height: 68,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.monitor_heart_outlined,
|
||||
color: Colors.white,
|
||||
size: 34,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TextField extends StatelessWidget {
|
||||
final TextEditingController ctrl; final String hint; final TextInputType type; final int maxLen; final Color accent;
|
||||
const _TextField(this.ctrl, this.hint, this.type, this.maxLen, this.accent);
|
||||
@override Widget build(BuildContext context) => Container(
|
||||
height: 50, decoration: BoxDecoration(color: const Color(0xFFF5F5F5), borderRadius: BorderRadius.circular(10)),
|
||||
child: TextField(
|
||||
controller: ctrl, keyboardType: type, maxLength: maxLen,
|
||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), border: InputBorder.none, enabledBorder: InputBorder.none, focusedBorder: InputBorder.none, counterText: '', contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 13)),
|
||||
),
|
||||
final TextEditingController ctrl;
|
||||
final String hint;
|
||||
final TextInputType type;
|
||||
final int maxLen;
|
||||
const _TextField(this.ctrl, this.hint, this.type, this.maxLen);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => TextField(
|
||||
controller: ctrl,
|
||||
keyboardType: type,
|
||||
maxLength: maxLen,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
decoration: InputDecoration(hintText: hint, counterText: ''),
|
||||
);
|
||||
}
|
||||
|
||||
class _RoleCard extends StatelessWidget {
|
||||
final bool selected; final IconData icon; final String label, desc; final Color color; final VoidCallback onTap;
|
||||
const _RoleCard({required this.selected, required this.icon, required this.label, required this.desc, required this.color, required this.onTap});
|
||||
@override Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(color: selected ? color : Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: selected ? color : const Color(0xFFE5E7EB), width: 1.5)),
|
||||
child: Column(children: [Icon(icon, size: 32, color: selected ? Colors.white : AppColors.textHint), const SizedBox(height: 6), Text(label, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: selected ? Colors.white : AppColors.textPrimary)), Text(desc, style: TextStyle(fontSize: 12, color: selected ? Colors.white70 : AppColors.textHint))]),
|
||||
),
|
||||
);
|
||||
class _DoctorSelector extends StatelessWidget {
|
||||
final String? name;
|
||||
final VoidCallback onTap;
|
||||
const _DoctorSelector({required this.name, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: 50,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
name ?? '请选择医生',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: name != null
|
||||
? AppColors.textPrimary
|
||||
: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Icon(Icons.keyboard_arrow_down, color: AppColors.textHint),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmsButton extends StatelessWidget {
|
||||
final bool sending;
|
||||
final int countdown;
|
||||
final VoidCallback? onTap;
|
||||
const _SmsButton({
|
||||
required this.sending,
|
||||
required this.countdown,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final disabled = countdown > 0 || sending;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 108,
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: disabled ? AppColors.cardInner : AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: disabled ? AppColors.border : AppColors.primary,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
sending
|
||||
? '发送中'
|
||||
: countdown > 0
|
||||
? '${countdown}s'
|
||||
: '获取验证码',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: disabled ? AppColors.textSecondary : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Agreement extends StatelessWidget {
|
||||
final bool agreed;
|
||||
final VoidCallback onTap;
|
||||
const _Agreement({required this.agreed, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 18,
|
||||
height: 18,
|
||||
margin: const EdgeInsets.only(right: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: agreed ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
border: Border.all(
|
||||
color: agreed ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: agreed
|
||||
? const Icon(Icons.check, size: 13, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
Flexible(
|
||||
child: RichText(
|
||||
text: const TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: '已阅读并同意',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
TextSpan(
|
||||
text: '《服务协议》',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: '和',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
TextSpan(
|
||||
text: '《隐私政策》',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _NoticeBanner extends StatelessWidget {
|
||||
final String text;
|
||||
final bool success;
|
||||
const _NoticeBanner({required this.text, required this.success});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: success ? AppColors.successLight : AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
success ? Icons.check_circle : Icons.error_outline,
|
||||
color: success ? AppColors.success : AppColors.error,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: success ? AppColors.success : AppColors.error,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PrimaryButton extends StatelessWidget {
|
||||
final bool loading;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
const _PrimaryButton({
|
||||
required this.loading,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
height: 52,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: loading
|
||||
? const SizedBox(
|
||||
width: 23,
|
||||
height: 23,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2.5,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,8 @@ class TrendPage extends ConsumerStatefulWidget {
|
||||
final String? metricType; // 可选初始选中指标
|
||||
const TrendPage({super.key, this.metricType});
|
||||
|
||||
@override ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||
@override
|
||||
ConsumerState<TrendPage> createState() => _TrendPageState();
|
||||
}
|
||||
|
||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
@@ -29,21 +30,29 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
];
|
||||
|
||||
static const _units = {
|
||||
'blood_pressure': 'mmHg', 'heart_rate': 'bpm',
|
||||
'glucose': 'mmol/L', 'spo2': '%', 'weight': 'kg',
|
||||
'blood_pressure': 'mmHg',
|
||||
'heart_rate': 'bpm',
|
||||
'glucose': 'mmol/L',
|
||||
'spo2': '%',
|
||||
'weight': 'kg',
|
||||
};
|
||||
|
||||
static const _names = {
|
||||
'blood_pressure': '血压', 'heart_rate': '心率',
|
||||
'glucose': '血糖', 'spo2': '血氧', 'weight': '体重',
|
||||
'blood_pressure': '血压',
|
||||
'heart_rate': '心率',
|
||||
'glucose': '血糖',
|
||||
'spo2': '血氧',
|
||||
'weight': '体重',
|
||||
};
|
||||
|
||||
String get _unit => _units[_selected] ?? '';
|
||||
String get _name => _names[_selected] ?? '';
|
||||
bool get _isBP => _selected == 'blood_pressure';
|
||||
Color get _color => _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||||
Color get _color =>
|
||||
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||||
|
||||
@override void initState() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.metricType != null) _selected = widget.metricType!;
|
||||
_loadAll();
|
||||
@@ -57,14 +66,19 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final all = <Map<String, dynamic>>[];
|
||||
for (final t in types) {
|
||||
try {
|
||||
final res = await api.get('/api/health-records/trend',
|
||||
queryParameters: {'type': t, 'period': 365});
|
||||
final list = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final res = await api.get(
|
||||
'/api/health-records/trend',
|
||||
queryParameters: {'type': t, 'period': 365},
|
||||
);
|
||||
final list =
|
||||
(res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
for (final r in list) {
|
||||
all.add({
|
||||
'id': r['id'],
|
||||
'type': t,
|
||||
'date': DateTime.tryParse(r['recordedAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
'date':
|
||||
DateTime.tryParse(r['recordedAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
'systolic': r['systolic'],
|
||||
'diastolic': r['diastolic'],
|
||||
'value': r['value'],
|
||||
@@ -72,11 +86,18 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
'isAbnormal': r['isAbnormal'] == true,
|
||||
});
|
||||
}
|
||||
} catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('[Trend] 加载趋势失败: $e');
|
||||
}
|
||||
}
|
||||
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
|
||||
all.sort(
|
||||
(a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime),
|
||||
);
|
||||
if (mounted) {
|
||||
setState(() { _allRecords = all; _loading = false; });
|
||||
setState(() {
|
||||
_allRecords = all;
|
||||
_loading = false;
|
||||
});
|
||||
_filter();
|
||||
}
|
||||
} catch (_) {
|
||||
@@ -85,14 +106,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}
|
||||
|
||||
void _filter() {
|
||||
final typeName = _selected == 'blood_pressure' ? 'BloodPressure'
|
||||
: _selected == 'heart_rate' ? 'HeartRate'
|
||||
: _selected == 'glucose' ? 'Glucose'
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
final typeName = _selected == 'blood_pressure'
|
||||
? 'BloodPressure'
|
||||
: _selected == 'heart_rate'
|
||||
? 'HeartRate'
|
||||
: _selected == 'glucose'
|
||||
? 'Glucose'
|
||||
: _selected == 'spo2'
|
||||
? 'SpO2'
|
||||
: 'Weight';
|
||||
setState(() {
|
||||
_filtered = _allRecords.where((r) => r['type'] == typeName).toList();
|
||||
_filtered.sort((a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime));
|
||||
_filtered.sort(
|
||||
(a, b) => (a['date'] as DateTime).compareTo(b['date'] as DateTime),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -108,65 +135,118 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => Padding(
|
||||
padding: EdgeInsets.only(bottom: MediaQuery.of(ctx).viewInsets.bottom, left: 20, right: 20, top: 20),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('录入$_name', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 16),
|
||||
if (_isBP) ...[
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '收缩压 (mmHg)', border: OutlineInputBorder()))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: TextField(controller: ctrl2, keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(labelText: '舒张压 (mmHg)', border: OutlineInputBorder()))),
|
||||
]),
|
||||
] else
|
||||
TextField(controller: ctrl1, keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(labelText: '$_name ($_unit)', border: const OutlineInputBorder())),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final v1 = double.tryParse(ctrl1.text.trim());
|
||||
if (v1 == null) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final body = <String, dynamic>{
|
||||
'type': _selected == 'blood_pressure' ? 'BloodPressure'
|
||||
: _selected == 'heart_rate' ? 'HeartRate'
|
||||
: _selected == 'glucose' ? 'Glucose'
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
body['systolic'] = v1.toInt();
|
||||
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||||
} else {
|
||||
body['value'] = v1;
|
||||
}
|
||||
await api.post('/api/health-records', data: body);
|
||||
Navigator.pop(ctx);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
_loadAll();
|
||||
} catch (_) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('录入失败')));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: _color, foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
|
||||
)),
|
||||
const SizedBox(height: 20),
|
||||
]),
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(ctx).viewInsets.bottom,
|
||||
left: 20,
|
||||
right: 20,
|
||||
top: 20,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'录入$_name',
|
||||
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_isBP) ...[
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: ctrl1,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '收缩压 (mmHg)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: ctrl2,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: '舒张压 (mmHg)',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
] else
|
||||
TextField(
|
||||
controller: ctrl1,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: InputDecoration(
|
||||
labelText: '$_name ($_unit)',
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
final v1 = double.tryParse(ctrl1.text.trim());
|
||||
if (v1 == null) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final body = <String, dynamic>{
|
||||
'type': _selected == 'blood_pressure'
|
||||
? 'BloodPressure'
|
||||
: _selected == 'heart_rate'
|
||||
? 'HeartRate'
|
||||
: _selected == 'glucose'
|
||||
? 'Glucose'
|
||||
: _selected == 'spo2'
|
||||
? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
body['systolic'] = v1.toInt();
|
||||
body['diastolic'] = int.tryParse(ctrl2.text.trim()) ?? 0;
|
||||
} else {
|
||||
body['value'] = v1;
|
||||
}
|
||||
await api.post('/api/health-records', data: body);
|
||||
Navigator.pop(ctx);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
_loadAll();
|
||||
} catch (_) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('录入失败')));
|
||||
}
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _color,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('确认录入', style: TextStyle(fontSize: 19)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
@@ -175,20 +255,24 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator(color: AppTheme.primary))
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primary),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _loadAll,
|
||||
child: SingleChildScrollView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(children: [
|
||||
_buildMetricTabs(),
|
||||
const SizedBox(height: 16),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 20),
|
||||
_buildHistory(),
|
||||
const SizedBox(height: 80),
|
||||
]),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMetricTabs(),
|
||||
const SizedBox(height: 16),
|
||||
_buildChart(),
|
||||
const SizedBox(height: 20),
|
||||
_buildHistory(),
|
||||
const SizedBox(height: 80),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -198,27 +282,33 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
Widget _buildMetricTabs() {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(children: _metrics.map((m) {
|
||||
final key = m['key'] as String;
|
||||
final color = m['color'] as Color;
|
||||
final sel = _selected == key;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchMetric(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: sel ? color : const Color(0xFFE0E0E0)),
|
||||
child: Row(
|
||||
children: _metrics.map((m) {
|
||||
final key = m['key'] as String;
|
||||
final color = m['color'] as Color;
|
||||
final sel = _selected == key;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchMetric(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? color : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: sel ? color : AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
m['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text(m['label'] as String, style: TextStyle(
|
||||
fontSize: 17, fontWeight: FontWeight.w600,
|
||||
color: sel ? Colors.white : const Color(0xFF666666)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -226,14 +316,35 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
Widget _buildChart() {
|
||||
if (_filtered.isEmpty) {
|
||||
return Container(
|
||||
height: 220, decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.show_chart, size: 48, color: Colors.grey[300]),
|
||||
const SizedBox(height: 8),
|
||||
Text('暂无$_name数据', style: const TextStyle(color: Color(0xFFBBBBBB))),
|
||||
const SizedBox(height: 4),
|
||||
const Text('点击右下角 + 手动录入', style: TextStyle(fontSize: 15, color: Color(0xFFCCCCCC))),
|
||||
])),
|
||||
height: 220,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.show_chart,
|
||||
size: 48,
|
||||
color: AppColors.textHint.withValues(alpha: 0.45),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'暂无$_name数据',
|
||||
style: const TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const Text(
|
||||
'点击右下角 + 手动录入',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -241,7 +352,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
double minV = double.infinity, maxV = double.negativeInfinity;
|
||||
for (int i = 0; i < _filtered.length; i++) {
|
||||
final r = _filtered[i];
|
||||
final v = _isBP ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble();
|
||||
final v = _isBP
|
||||
? (r['systolic'] as num?)?.toDouble()
|
||||
: (r['value'] as num?)?.toDouble();
|
||||
if (v == null) continue;
|
||||
if (v < minV) minV = v;
|
||||
if (v > maxV) maxV = v;
|
||||
@@ -251,169 +364,318 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
if (spots.isEmpty) return const SizedBox.shrink();
|
||||
final range = maxV - minV;
|
||||
final padding = range * 0.15;
|
||||
if (padding < 1) { minV -= 5; maxV += 5; }
|
||||
else { minV -= padding; maxV += padding; }
|
||||
if (padding < 1) {
|
||||
minV -= 5;
|
||||
maxV += 5;
|
||||
} else {
|
||||
minV -= padding;
|
||||
maxV += padding;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
const SizedBox(width: 8),
|
||||
Text(_name, style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _color)),
|
||||
const SizedBox(width: 6),
|
||||
Text('趋势 (${_filtered.length}条)', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
|
||||
const Spacer(),
|
||||
Text('单位: $_unit', style: const TextStyle(fontSize: 14, color: Color(0xFFBBBBBB))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (spots.length - 1).toDouble(),
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: FlGridData(show: true, drawVerticalLine: false,
|
||||
horizontalInterval: range > 50 ? 10 : range > 20 ? 5 : range > 5 ? 2 : 1),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 40,
|
||||
getTitlesWidget: (v, meta) => Text(v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFF999999))),
|
||||
)),
|
||||
bottomTitles: AxisTitles(sideTitles: SideTitles(
|
||||
showTitles: true, reservedSize: 24,
|
||||
interval: spots.length > 30 ? 7 : spots.length > 14 ? 3 : 1,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return const SizedBox.shrink();
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
return Text('${d.month}/${d.day}', style: const TextStyle(fontSize: 12, color: Color(0xFFBBBBBB)));
|
||||
},
|
||||
)),
|
||||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_name,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 60,
|
||||
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||||
radius: 3, color: _color, strokeWidth: 1, strokeColor: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
'趋势 (${_filtered.length}条)',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'单位: $_unit',
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
height: 200,
|
||||
child: LineChart(
|
||||
LineChartData(
|
||||
minX: 0,
|
||||
maxX: (spots.length - 1).toDouble(),
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: FlGridData(
|
||||
show: true,
|
||||
drawVerticalLine: false,
|
||||
horizontalInterval: range > 50
|
||||
? 10
|
||||
: range > 20
|
||||
? 5
|
||||
: range > 5
|
||||
? 2
|
||||
: 1,
|
||||
),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
getTitlesWidget: (v, meta) => Text(
|
||||
v.toStringAsFixed(v.truncateToDouble() == v ? 0 : 1),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(show: true,
|
||||
color: _color.withAlpha(20)),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 24,
|
||||
interval: spots.length > 30
|
||||
? 7
|
||||
: spots.length > 14
|
||||
? 3
|
||||
: 1,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length)
|
||||
return const SizedBox.shrink();
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
return Text(
|
||||
'${d.month}/${d.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots.map((s) {
|
||||
final idx = s.x.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return null;
|
||||
final r = _filtered[idx];
|
||||
final d = r['date'] as DateTime;
|
||||
final v = _isBP ? '${r['systolic']}/${r['diastolic']}' : '${r['value']}';
|
||||
return LineTooltipItem(
|
||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||
TextStyle(color: _color, fontSize: 15, fontWeight: FontWeight.w600),
|
||||
);
|
||||
}).toList(),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: true,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 60,
|
||||
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
|
||||
radius: 3,
|
||||
color: _color,
|
||||
strokeWidth: 1,
|
||||
strokeColor: Colors.white,
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: _color.withAlpha(20),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
touchTooltipData: LineTouchTooltipData(
|
||||
getTooltipItems: (spots) => spots.map((s) {
|
||||
final idx = s.x.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) return null;
|
||||
final r = _filtered[idx];
|
||||
final d = r['date'] as DateTime;
|
||||
final v = _isBP
|
||||
? '${r['systolic']}/${r['diastolic']}'
|
||||
: '${r['value']}';
|
||||
return LineTooltipItem(
|
||||
'${d.month}/${d.day} ${d.hour}:${d.minute.toString().padLeft(2, '0')}\n$v $_unit',
|
||||
TextStyle(
|
||||
color: _color,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 历史记录列表 ----
|
||||
Widget _buildHistory() {
|
||||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('历史记录', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
Text('${_filtered.length}条', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
final date = r['date'] as DateTime;
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
final id = r['id']?.toString() ?? '';
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Dismissible(
|
||||
key: Key(id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(12)),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.delete('/api/health-records/$id');
|
||||
setState(() {
|
||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(children: [
|
||||
Container(width: 40, height: 40, decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20), borderRadius: BorderRadius.circular(10)),
|
||||
child: Center(child: Text(_getMetricIcon(_selected), style: const TextStyle(fontSize: 21))),
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Text(
|
||||
'历史记录',
|
||||
style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${_filtered.length}条',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 2),
|
||||
Text('$display $_unit', style: TextStyle(fontSize: 23, fontWeight: FontWeight.w700,
|
||||
color: abnormal ? const Color(0xFFEF4444) : const Color(0xFF1A1A1A))),
|
||||
])),
|
||||
if (abnormal)
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFEE2E2), borderRadius: BorderRadius.circular(6)),
|
||||
child: const Text('异常', style: TextStyle(fontSize: 14, color: Color(0xFFEF4444), fontWeight: FontWeight.w500))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}),
|
||||
]);
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
final date = r['date'] as DateTime;
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
final id = r['id']?.toString() ?? '';
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Dismissible(
|
||||
key: Key(id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.delete('/api/health-records/$id');
|
||||
setState(() {
|
||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Text(
|
||||
_getMetricIcon(_selected),
|
||||
style: const TextStyle(fontSize: 21),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$display $_unit',
|
||||
style: TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: abnormal
|
||||
? AppColors.error
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (abnormal)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'异常',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _getMetricIcon(String key) {
|
||||
switch (key) {
|
||||
case 'blood_pressure': return '🫀';
|
||||
case 'heart_rate': return '💓';
|
||||
case 'glucose': return '🩸';
|
||||
case 'spo2': return '🫁';
|
||||
case 'weight': return '⚖️';
|
||||
default: return '📊';
|
||||
case 'blood_pressure':
|
||||
return '🫀';
|
||||
case 'heart_rate':
|
||||
return '💓';
|
||||
case 'glucose':
|
||||
return '🩸';
|
||||
case 'spo2':
|
||||
return '🫁';
|
||||
case 'weight':
|
||||
return '⚖️';
|
||||
default:
|
||||
return '📊';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../providers/consultation_provider.dart';
|
||||
|
||||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||
@@ -67,12 +64,28 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
title: Column(children: [
|
||||
Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话',
|
||||
style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
if (state.doctorName.isNotEmpty)
|
||||
Text(_statusText(state.status), style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
|
||||
]),
|
||||
title: Column(
|
||||
children: [
|
||||
Text(
|
||||
state.doctorName.isNotEmpty
|
||||
? '${state.doctorName} · ${state.doctorDepartment}'
|
||||
: '问诊对话',
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF1A1A1A),
|
||||
),
|
||||
),
|
||||
if (state.doctorName.isNotEmpty)
|
||||
Text(
|
||||
_statusText(state.status),
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Container(
|
||||
@@ -82,24 +95,35 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary, fontWeight: FontWeight.w500)),
|
||||
child: Text(
|
||||
'剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state.isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: AppTheme.primaryLight))
|
||||
: Column(children: [
|
||||
Expanded(child: _buildMessageList(state)),
|
||||
_buildInputBar(canSend),
|
||||
]),
|
||||
? const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primaryLight),
|
||||
)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(child: _buildMessageList(state)),
|
||||
_buildInputBar(canSend),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageList(ConsultationChatState state) {
|
||||
if (state.messages.isEmpty) {
|
||||
return const Center(
|
||||
child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB))));
|
||||
child: Text('暂无消息', style: TextStyle(color: Color(0xFFBBBBBB))),
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
@@ -118,7 +142,9 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.82),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.only(
|
||||
@@ -127,44 +153,86 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
bottomLeft: const Radius.circular(20),
|
||||
bottomRight: const Radius.circular(20),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
border: isUser
|
||||
? null
|
||||
: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
boxShadow: isUser
|
||||
? []
|
||||
: [BoxShadow(color: AppTheme.primary.withAlpha(12), blurRadius: 10, offset: const Offset(0, 3))],
|
||||
: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withAlpha(12),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (!isUser && msg.senderName != null) ...[
|
||||
Row(children: [
|
||||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFF0F2FF),
|
||||
child: Icon(Icons.smart_toy, size: 15, color: AppTheme.primaryLight)),
|
||||
const SizedBox(width: 6),
|
||||
Text(msg.senderName!, style: const TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (isUser)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.4))
|
||||
else
|
||||
MarkdownBody(
|
||||
data: msg.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(fontSize: 19, color: Color(0xFF1A1A1A), height: 1.5),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser && msg.senderName != null) ...[
|
||||
Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: AppColors.iconBg,
|
||||
child: Icon(
|
||||
Icons.forum_outlined,
|
||||
size: 15,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
msg.senderName!,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isAi) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (isUser)
|
||||
Text(
|
||||
msg.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: Colors.white,
|
||||
height: 1.4,
|
||||
),
|
||||
)
|
||||
else
|
||||
MarkdownBody(
|
||||
data: msg.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: Color(0xFF1A1A1A),
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: Text('🏷️ 以上为AI分析,具体请咨询${state.doctorName}',
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFFF9A825))),
|
||||
),
|
||||
if (isAi) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8E1),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(
|
||||
'🏷️ 以上为AI分析,具体请咨询${state.doctorName}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFFF9A825),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -174,46 +242,72 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))),
|
||||
border: const Border(top: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSoft,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
enabled: canSend,
|
||||
maxLines: null,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
||||
hintStyle: const TextStyle(fontSize: 18, color: Color(0xFFBBBBBB)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
enabledBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSoft,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
enabled: canSend,
|
||||
maxLines: null,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
decoration: InputDecoration(
|
||||
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: Color(0xFFBBBBBB),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
border: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
borderSide: BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: const OutlineInputBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
borderSide: BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: canSend ? _send : null,
|
||||
child: Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: canSend ? AppColors.blueMeasure : const Color(0xFFCCCCCC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: canSend ? _send : null,
|
||||
child: Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: canSend
|
||||
? AppColors.blueMeasure
|
||||
: const Color(0xFFCCCCCC),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.send_rounded,
|
||||
size: 20,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
child: const Icon(Icons.send_rounded, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,40 +3,60 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/omron_device_provider.dart';
|
||||
import '../../services/omron_ble_service.dart';
|
||||
|
||||
class DeviceManagementPage extends ConsumerStatefulWidget {
|
||||
const DeviceManagementPage({super.key});
|
||||
@override ConsumerState<DeviceManagementPage> createState() => _DeviceManagementPageState();
|
||||
@override
|
||||
ConsumerState<DeviceManagementPage> createState() =>
|
||||
_DeviceManagementPageState();
|
||||
}
|
||||
|
||||
class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||
bool _reconnecting = false;
|
||||
OverlayEntry? _toast;
|
||||
|
||||
@override void dispose() { _hideToast(); super.dispose(); }
|
||||
@override
|
||||
void dispose() {
|
||||
_hideToast();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _showToast(String msg, {bool success = false}) {
|
||||
_hideToast();
|
||||
_toast = OverlayEntry(
|
||||
builder: (_) => Positioned(
|
||||
top: MediaQuery.of(context).padding.top + 8, left: 16, right: 16,
|
||||
top: MediaQuery.of(context).padding.top + 8,
|
||||
left: 16,
|
||||
right: 16,
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0), duration: const Duration(milliseconds: 250),
|
||||
builder: (_, v, __) => Opacity(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
builder: (context, v, child) => Opacity(
|
||||
opacity: v,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: success ? const Color(0xFF059669) : const Color(0xFFDC2626),
|
||||
color: success ? AppColors.success : AppColors.error,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: const [BoxShadow(color: Colors.black26, blurRadius: 8, offset: Offset(0, 2))],
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
success ? Icons.check_circle : Icons.info_outline,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
msg,
|
||||
style: const TextStyle(color: Colors.white, fontSize: 14),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(success ? Icons.check_circle : Icons.info_outline, color: Colors.white, size: 18),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: Text(msg, style: const TextStyle(color: Colors.white, fontSize: 14))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -46,7 +66,10 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||
Future.delayed(const Duration(seconds: 2), _hideToast);
|
||||
}
|
||||
|
||||
void _hideToast() { _toast?.remove(); _toast = null; }
|
||||
void _hideToast() {
|
||||
_toast?.remove();
|
||||
_toast = null;
|
||||
}
|
||||
|
||||
Future<void> _reconnect(String mac) async {
|
||||
if (_reconnecting) return;
|
||||
@@ -61,15 +84,18 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final device = ref.watch(omronDeviceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: const Text('蓝牙设备', style: TextStyle(color: AppColors.textPrimary)),
|
||||
title: const Text('蓝牙设备'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: AppColors.primary),
|
||||
@@ -85,139 +111,279 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage> {
|
||||
Widget _buildEmpty() => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 80, height: 80,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF0F0F0), borderRadius: BorderRadius.circular(24)),
|
||||
child: const Icon(Icons.bluetooth_disabled, size: 40, color: Color(0xFFBBBBBB)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('暂无设备', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 6),
|
||||
const Text('点击右上角 + 添加血压计', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 82,
|
||||
height: 82,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_disabled,
|
||||
size: 40,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'暂无设备',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'点击右上角 + 添加血压计',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _buildDeviceList(DeviceBindState d) => ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 设备卡片
|
||||
GestureDetector(
|
||||
onTap: d.isConnected ? null : () => _reconnect(d.mac ?? ''),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
duration: const Duration(milliseconds: 250),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFEEEEEE),
|
||||
width: d.isConnected ? 1.5 : 1,
|
||||
color: d.isConnected ? AppColors.success : AppColors.border,
|
||||
),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isConnected ? const Color(0xFFD1FAE5) : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(Icons.bluetooth, size: 24,
|
||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(d.name ?? '血压计', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text(d.mac ?? '', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
if (d.lastSync != null)
|
||||
Text('上次同步: ${d.lastSync}', style: const TextStyle(fontSize: 11, color: AppColors.textHint)),
|
||||
])),
|
||||
if (_reconnecting)
|
||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
else ...[
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8, height: 8,
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isConnected ? const Color(0xFF10B981) : const Color(0xFFBBBBBB),
|
||||
shape: BoxShape.circle,
|
||||
boxShadow: d.isConnected ? [BoxShadow(color: const Color(0xFF10B981).withOpacity(0.4), blurRadius: 4)] : null,
|
||||
color: d.isConnected
|
||||
? AppColors.successLight
|
||||
: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(15),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.bluetooth,
|
||||
size: 24,
|
||||
color: d.isConnected ? AppColors.success : AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
d.isConnected ? '已连接' : '未连接',
|
||||
style: TextStyle(fontSize: 13, color: d.isConnected ? const Color(0xFF10B981) : AppColors.textHint),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
d.name ?? '血压计',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
d.mac ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
if (d.lastSync != null)
|
||||
Text(
|
||||
'上次同步: ${d.lastSync}',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_reconnecting)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else ...[
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
color: d.isConnected
|
||||
? AppColors.success
|
||||
: AppColors.textHint,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
d.isConnected ? '已连接' : '未连接',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: d.isConnected
|
||||
? AppColors.success
|
||||
: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 最近读数
|
||||
if (d.lastReading != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Row(children: [
|
||||
const Text('最近测量', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
const Spacer(),
|
||||
Text(d.lastReading!.display, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('mmHg', style: TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
if (d.lastReading!.pulse != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Text('${d.lastReading!.pulse} bpm', style: const TextStyle(fontSize: 14, color: AppColors.primary)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Text(
|
||||
'最近测量',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
d.lastReading!.display,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'mmHg',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
),
|
||||
if (d.lastReading!.pulse != null) ...[
|
||||
const SizedBox(width: 16),
|
||||
Text(
|
||||
'${d.lastReading!.pulse} bpm',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 16),
|
||||
// 解绑
|
||||
SizedBox(width: double.infinity, child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text('解绑设备'),
|
||||
content: const Text('解绑后需重新扫描连接,确定吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'), style: TextButton.styleFrom(foregroundColor: AppColors.error)),
|
||||
],
|
||||
));
|
||||
if (ok == true) await ref.read(omronDeviceProvider.notifier).unbind();
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error, side: const BorderSide(color: Color(0xFFFECACA)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('解绑设备'),
|
||||
content: const Text('解绑后需要重新扫描连接,确定吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await ref.read(omronDeviceProvider.notifier).unbind();
|
||||
}
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.errorLight),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
child: const Text('解绑设备'),
|
||||
),
|
||||
child: const Text('解绑设备'),
|
||||
)),
|
||||
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('使用说明', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
_tip('1', '血压计装好电池,绑好袖带'),
|
||||
_tip('2', '按开始键测量,结束后设备自动进入通信模式'),
|
||||
_tip('3', '点击设备栏即可自动连接并同步数据'),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'使用说明',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_tip('1', '血压计装好电池,绑好袖带'),
|
||||
_tip('2', '按开始键测量,结束后设备自动进入通信模式'),
|
||||
_tip('3', '点击设备卡片即可自动连接并同步数据'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _tip(String n, String t) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('$n.', style: const TextStyle(fontSize: 13, color: AppColors.primary, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(child: Text(t, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))),
|
||||
]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'$n.',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Expanded(
|
||||
child: Text(
|
||||
t,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io' show Platform;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
@@ -13,7 +12,8 @@ import '../../services/omron_ble_service.dart';
|
||||
|
||||
class DeviceScanPage extends ConsumerStatefulWidget {
|
||||
const DeviceScanPage({super.key});
|
||||
@override ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
@override
|
||||
ConsumerState<DeviceScanPage> createState() => _DeviceScanPageState();
|
||||
}
|
||||
|
||||
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
@@ -33,21 +33,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
late AnimationController _pulseCtrl;
|
||||
late Animation<double> _pulseAnim;
|
||||
|
||||
@override void initState() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseCtrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200));
|
||||
_pulseAnim = Tween(begin: 0.8, end: 1.4).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
_pulseAnim = Tween(
|
||||
begin: 0.8,
|
||||
end: 1.4,
|
||||
).animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
_pulseCtrl.repeat(reverse: true);
|
||||
|
||||
_scanSub = FlutterBluePlus.scanResults.listen((list) {
|
||||
if (!mounted) return;
|
||||
final bpList = list.where((r) => OmronBleService.isBpDevice(r)).toList();
|
||||
setState(() { _results.clear(); _results.addAll(bpList); });
|
||||
setState(() {
|
||||
_results.clear();
|
||||
_results.addAll(bpList);
|
||||
});
|
||||
});
|
||||
_start();
|
||||
}
|
||||
|
||||
@override void dispose() {
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseCtrl.dispose();
|
||||
_scanSub?.cancel();
|
||||
_readingSub?.cancel();
|
||||
@@ -57,7 +68,10 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
}
|
||||
|
||||
Future<void> _start() async {
|
||||
setState(() { _scanning = true; _connected = false; });
|
||||
setState(() {
|
||||
_scanning = true;
|
||||
_connected = false;
|
||||
});
|
||||
_results.clear();
|
||||
|
||||
await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
|
||||
@@ -66,17 +80,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
final locStatus = await Permission.locationWhenInUse.serviceStatus;
|
||||
if (locStatus != ServiceStatus.enabled && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请先打开GPS定位,否则无法扫描蓝牙'), backgroundColor: AppColors.warning),
|
||||
const SnackBar(
|
||||
content: Text('请先打开GPS定位,否则无法扫描蓝牙'),
|
||||
backgroundColor: AppColors.warning,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final adapterState = await FlutterBluePlus.adapterState.first;
|
||||
if (adapterState != BluetoothAdapterState.on) {
|
||||
try { await FlutterBluePlus.turnOn(); } catch (_) {}
|
||||
try {
|
||||
await FlutterBluePlus.turnOn();
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
try { await FlutterBluePlus.stopScan(); } catch (_) {}
|
||||
try {
|
||||
await FlutterBluePlus.stopScan();
|
||||
} catch (_) {}
|
||||
await FlutterBluePlus.startScan(
|
||||
timeout: const Duration(seconds: 30),
|
||||
androidScanMode: AndroidScanMode.lowLatency,
|
||||
@@ -100,7 +121,9 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
? r.advertisementData.localName
|
||||
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
|
||||
|
||||
await ref.read(omronDeviceProvider.notifier).bind(r.device.remoteId.toString(), name);
|
||||
await ref
|
||||
.read(omronDeviceProvider.notifier)
|
||||
.bind(r.device.remoteId.toString(), name);
|
||||
|
||||
_bleConnSub = r.device.connectionState.listen((s) {
|
||||
if (s == BluetoothConnectionState.disconnected && mounted) {
|
||||
@@ -109,7 +132,9 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
if (_readingReceived) {
|
||||
popRoute(ref);
|
||||
} else {
|
||||
setState(() { _connected = false; });
|
||||
setState(() {
|
||||
_connected = false;
|
||||
});
|
||||
_start();
|
||||
}
|
||||
}
|
||||
@@ -125,9 +150,13 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
_pulse = reading.pulse;
|
||||
});
|
||||
try {
|
||||
await ref.read(apiClientProvider).post('/api/health-records', data: reading.toHealthRecord());
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.post('/api/health-records', data: reading.toHealthRecord());
|
||||
await ref.read(omronDeviceProvider.notifier).onReading(reading);
|
||||
} catch (e) { debugPrint('[PAGE] 上报失败: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('[PAGE] 上报失败: $e');
|
||||
}
|
||||
Future.delayed(const Duration(milliseconds: 1500), () {
|
||||
if (mounted) popRoute(ref);
|
||||
});
|
||||
@@ -152,13 +181,17 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Text(_connected ? _deviceName : '添加设备', style: const TextStyle(color: AppColors.textPrimary)),
|
||||
title: Text(
|
||||
_connected ? _deviceName : '添加设备',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () {
|
||||
@@ -172,126 +205,243 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
}
|
||||
|
||||
// ── 扫描视图 ──
|
||||
Widget _buildScan() => Column(children: [
|
||||
if (_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()),
|
||||
if (!_scanning && _results.isEmpty) Expanded(child: _buildScanningCenter()),
|
||||
if (_results.isNotEmpty)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (_, i) => _buildTile(_results[i]),
|
||||
),
|
||||
),
|
||||
if (!_scanning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(width: double.infinity, child: OutlinedButton.icon(
|
||||
onPressed: _start,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新扫描'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: Color(0xFFE5E7EB)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
Widget _buildScan() => Column(
|
||||
children: [
|
||||
if (_scanning && _results.isEmpty)
|
||||
Expanded(child: _buildScanningCenter()),
|
||||
if (!_scanning && _results.isEmpty)
|
||||
Expanded(child: _buildScanningCenter()),
|
||||
if (_results.isNotEmpty)
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: _results.length,
|
||||
itemBuilder: (_, i) => _buildTile(_results[i]),
|
||||
),
|
||||
)),
|
||||
),
|
||||
]);
|
||||
|
||||
Widget _buildScanningCenter() => Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Stack(alignment: Alignment.center, children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, child) => Container(
|
||||
width: 80 * _pulseAnim.value, height: 80 * _pulseAnim.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withOpacity(0.08),
|
||||
),
|
||||
if (!_scanning)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _start,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('重新扫描'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64, height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(Icons.bluetooth_searching, size: 32, color: AppColors.primary),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _buildScanningCenter() => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, child) => Container(
|
||||
width: 80 * _pulseAnim.value,
|
||||
height: 80 * _pulseAnim.value,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.avatarBg,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_searching,
|
||||
size: 32,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
Text(_scanning ? '正在搜索蓝牙设备...' : '未发现设备', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||||
const SizedBox(height: 8),
|
||||
Text(_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索', style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC))),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
_scanning ? '正在搜索蓝牙设备...' : '未发现设备',
|
||||
style: const TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_scanning ? '请确保血压计处于通信模式' : '点击下方按钮重新搜索',
|
||||
style: const TextStyle(fontSize: 13, color: Color(0xFFCCCCCC)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
// ── 已连接视图 ──
|
||||
Widget _buildConnected() => Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(32),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: 100, height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: _readingReceived ? const Color(0xFFD1FAE5) : const Color(0xFFF0F0FF),
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 500),
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: _readingReceived
|
||||
? AppColors.successLight
|
||||
: AppColors.avatarBg,
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
),
|
||||
child: Icon(
|
||||
_readingReceived
|
||||
? Icons.check_rounded
|
||||
: Icons.bluetooth_connected,
|
||||
size: 48,
|
||||
color: _readingReceived
|
||||
? const Color(0xFF10B981)
|
||||
: AppColors.primary,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
_readingReceived ? Icons.check_rounded : Icons.bluetooth_connected,
|
||||
size: 48,
|
||||
color: _readingReceived ? const Color(0xFF10B981) : AppColors.primary,
|
||||
const SizedBox(height: 24),
|
||||
Text(
|
||||
_readingReceived ? '测量完成' : '设备已连接',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
Text(_readingReceived ? '测量完成' : '设备已连接', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
if (_readingReceived) ...[
|
||||
Row(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
Text('$_systolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)),
|
||||
Padding(padding: const EdgeInsets.only(bottom: 10), child: Text('/', style: TextStyle(fontSize: 24, color: AppColors.textHint.withOpacity(0.5)))),
|
||||
Text('$_diastolic', style: const TextStyle(fontSize: 56, fontWeight: FontWeight.w800, color: AppColors.textPrimary, height: 1)),
|
||||
const SizedBox(width: 8),
|
||||
Padding(padding: const EdgeInsets.only(bottom: 12), child: Text('mmHg', style: TextStyle(fontSize: 15, color: AppColors.textHint))),
|
||||
]),
|
||||
if (_pulse != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(color: const Color(0xFFEFF6FF), borderRadius: BorderRadius.circular(20)),
|
||||
child: Text('脉搏 $_pulse bpm', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: AppColors.primary)),
|
||||
if (_readingReceived) ...[
|
||||
Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$_systolic',
|
||||
style: const TextStyle(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
'/',
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppColors.textHint.withValues(alpha: 0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'$_diastolic',
|
||||
style: const TextStyle(
|
||||
fontSize: 56,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Text(
|
||||
'mmHg',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_pulse != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.infoLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Text(
|
||||
'脉搏 $_pulse bpm',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'数据已自动同步',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('数据已自动同步', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)),
|
||||
] else ...[
|
||||
const Text('请按下血压计的开始键进行测量', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, __) => Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: AppColors.primary.withOpacity(0.3), width: 2),
|
||||
),
|
||||
child: Center(
|
||||
child: Transform.scale(
|
||||
scale: _pulseAnim.value,
|
||||
child: Container(width: 12, height: 12, decoration: const BoxDecoration(shape: BoxShape.circle, color: AppColors.primary)),
|
||||
] else ...[
|
||||
const Text(
|
||||
'请按下血压计的开始键进行测量',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (_, __) => Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.3),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Transform.scale(
|
||||
scale: _pulseAnim.value,
|
||||
child: Container(
|
||||
width: 12,
|
||||
height: 12,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -308,32 +458,78 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(color: const Color(0xFFF0F0FF), borderRadius: BorderRadius.circular(12)),
|
||||
child: const Icon(Icons.bluetooth, size: 22, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 2),
|
||||
Text('${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
])),
|
||||
if (isConnecting)
|
||||
const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2))
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => _connect(r),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(12)),
|
||||
child: const Text('连接', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.avatarBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth,
|
||||
size: 22,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${r.device.remoteId} · 信号: ${_rssiLabel(r.rssi)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isConnecting)
|
||||
const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () => _connect(r),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 20,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text(
|
||||
'连接',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,14 +3,14 @@ import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/sse_handler.dart';
|
||||
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(
|
||||
DietNotifier.new,
|
||||
);
|
||||
|
||||
class DietState {
|
||||
final String? imagePath;
|
||||
@@ -83,17 +83,30 @@ class DietNotifier extends Notifier<DietState> {
|
||||
final path = state.imagePath!;
|
||||
final imageFile = File(path);
|
||||
final formData = FormData.fromMap({
|
||||
'images': await MultipartFile.fromFile(imageFile.path, filename: imageFile.path.split('/').last),
|
||||
'images': await MultipartFile.fromFile(
|
||||
imageFile.path,
|
||||
filename: imageFile.path.split('/').last,
|
||||
),
|
||||
});
|
||||
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
||||
final res = await api.dio.post(
|
||||
'/api/ai/analyze-food-image',
|
||||
data: formData,
|
||||
);
|
||||
final data = res.data;
|
||||
if (data['code'] != 0) {
|
||||
state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败');
|
||||
state = state.copyWith(
|
||||
isAnalyzing: false,
|
||||
errorMessage: data['message'] ?? '识别失败',
|
||||
);
|
||||
return;
|
||||
}
|
||||
final raw = data['data'] as String? ?? '[]';
|
||||
final foods = _parseFoodItems(raw);
|
||||
state = state.copyWith(foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null);
|
||||
state = state.copyWith(
|
||||
foods: foods,
|
||||
isAnalyzing: false,
|
||||
healthScore: foods.isNotEmpty ? 3 : null,
|
||||
);
|
||||
if (foods.isNotEmpty) _fetchCommentary(foods);
|
||||
} catch (e) {
|
||||
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
||||
@@ -105,7 +118,9 @@ class DietNotifier extends Notifier<DietState> {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final token = await api.accessToken;
|
||||
if (token == null) return;
|
||||
final names = foods.map((f) => '${f.name}(${f.portion},${f.calories}kcal)').join('、');
|
||||
final names = foods
|
||||
.map((f) => '${f.name}(${f.portion},${f.calories}kcal)')
|
||||
.join('、');
|
||||
final stream = SseHandler.connect(
|
||||
agentType: 'default',
|
||||
message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。',
|
||||
@@ -113,7 +128,8 @@ class DietNotifier extends Notifier<DietState> {
|
||||
);
|
||||
String text = '';
|
||||
await for (final event in stream) {
|
||||
if (event['action'] == 'answer') text += (event['data'] as String?) ?? '';
|
||||
if (event['action'] == 'answer')
|
||||
text += (event['data'] as String?) ?? '';
|
||||
if (event['action'] == 'status') {
|
||||
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
|
||||
}
|
||||
@@ -146,48 +162,160 @@ class DietNotifier extends Notifier<DietState> {
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
return [
|
||||
FoodItem(id: 'food_${DateTime.now().millisecondsSinceEpoch}', name: '识别结果(手动编辑)', portion: raw.length > 50 ? raw.substring(0, 50) : raw, calories: 0, selected: true),
|
||||
FoodItem(
|
||||
id: 'food_${DateTime.now().millisecondsSinceEpoch}',
|
||||
name: '识别结果(手动编辑)',
|
||||
portion: raw.length > 50 ? raw.substring(0, 50) : raw,
|
||||
calories: 0,
|
||||
selected: true,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
void updateFoodName(String id, String name) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
||||
void updateFoodPortion(String id, String portion) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
||||
void updateFoodCalories(String id, int calories) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
||||
void toggleFood(String id) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList(); state = state.copyWith(foods: foods); }
|
||||
void addFood() { final newId = '${DateTime.now().millisecondsSinceEpoch}'; state = state.copyWith(foods: [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)]); }
|
||||
void removeFood(String id) { state = state.copyWith(foods: state.foods.where((f) => f.id != id).toList()); }
|
||||
void setMealType(String type) { state = state.copyWith(mealType: type); }
|
||||
void reset() { state = DietState(); }
|
||||
void updateFoodName(String id, String name) {
|
||||
final foods = state.foods
|
||||
.map(
|
||||
(f) => f.id == id
|
||||
? FoodItem(
|
||||
id: f.id,
|
||||
name: name,
|
||||
portion: f.portion,
|
||||
calories: f.calories,
|
||||
selected: f.selected,
|
||||
)
|
||||
: f,
|
||||
)
|
||||
.toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void updateFoodPortion(String id, String portion) {
|
||||
final foods = state.foods
|
||||
.map(
|
||||
(f) => f.id == id
|
||||
? FoodItem(
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
portion: portion,
|
||||
calories: f.calories,
|
||||
selected: f.selected,
|
||||
)
|
||||
: f,
|
||||
)
|
||||
.toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void updateFoodCalories(String id, int calories) {
|
||||
final foods = state.foods
|
||||
.map(
|
||||
(f) => f.id == id
|
||||
? FoodItem(
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
portion: f.portion,
|
||||
calories: calories,
|
||||
selected: f.selected,
|
||||
)
|
||||
: f,
|
||||
)
|
||||
.toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void toggleFood(String id) {
|
||||
final foods = state.foods
|
||||
.map(
|
||||
(f) => f.id == id
|
||||
? FoodItem(
|
||||
id: f.id,
|
||||
name: f.name,
|
||||
portion: f.portion,
|
||||
calories: f.calories,
|
||||
selected: !f.selected,
|
||||
)
|
||||
: f,
|
||||
)
|
||||
.toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void addFood() {
|
||||
final newId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||
state = state.copyWith(
|
||||
foods: [
|
||||
...state.foods,
|
||||
FoodItem(id: newId, name: '新食物', portion: '', calories: 100),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void removeFood(String id) {
|
||||
state = state.copyWith(
|
||||
foods: state.foods.where((f) => f.id != id).toList(),
|
||||
);
|
||||
}
|
||||
|
||||
void setMealType(String type) {
|
||||
state = state.copyWith(mealType: type);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = DietState();
|
||||
}
|
||||
|
||||
Future<void> saveRecord() async {
|
||||
final selectedFoods = state.foods.where((f) => f.selected).toList();
|
||||
if (selectedFoods.isEmpty) return;
|
||||
final api = ref.read(apiClientProvider);
|
||||
final mealMap = {'breakfast': 'Breakfast', 'lunch': 'Lunch', 'dinner': 'Dinner', 'snack': 'Snack'};
|
||||
final mealMap = {
|
||||
'breakfast': 'Breakfast',
|
||||
'lunch': 'Lunch',
|
||||
'dinner': 'Dinner',
|
||||
'snack': 'Snack',
|
||||
};
|
||||
final totalCal = selectedFoods.fold<int>(0, (s, f) => s + f.calories);
|
||||
await api.post('/api/diet-records', data: {
|
||||
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
||||
'totalCalories': totalCal,
|
||||
'healthScore': state.healthScore ?? 3,
|
||||
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
||||
'foodItems': selectedFoods.asMap().entries.map((e) => {'name': e.value.name, 'portion': e.value.portion, 'calories': e.value.calories, 'sortOrder': e.key}).toList(),
|
||||
});
|
||||
await api.post(
|
||||
'/api/diet-records',
|
||||
data: {
|
||||
'mealType': mealMap[state.mealType] ?? 'Lunch',
|
||||
'totalCalories': totalCal,
|
||||
'healthScore': state.healthScore ?? 3,
|
||||
'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
|
||||
'foodItems': selectedFoods
|
||||
.asMap()
|
||||
.entries
|
||||
.map(
|
||||
(e) => {
|
||||
'name': e.value.name,
|
||||
'portion': e.value.portion,
|
||||
'calories': e.value.calories,
|
||||
'sortOrder': e.key,
|
||||
},
|
||||
)
|
||||
.toList(),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
|
||||
const _dietAccent = Color(0xFFF0A060);
|
||||
const _dietAccentLight = Color(0xFFFFF2E8);
|
||||
const _dietBg = Color(0xFFFFFBF7);
|
||||
const _dietAccent = AppColors.primary;
|
||||
const _dietAccentLight = AppColors.primarySoft;
|
||||
const _dietBg = AppColors.background;
|
||||
|
||||
class DietCapturePage extends ConsumerStatefulWidget {
|
||||
const DietCapturePage({super.key});
|
||||
@override ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||
@override
|
||||
ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||
}
|
||||
|
||||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
@override void initState() { super.initState(); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -196,11 +324,19 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
backgroundColor: _dietBg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
title: const Text('饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)),
|
||||
title: const Text(
|
||||
'饮食分析',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: state.imagePath == null
|
||||
? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary, fontSize: 16)))
|
||||
? const Center(
|
||||
child: Text(
|
||||
'请从首页拍摄或选择食物照片',
|
||||
style: TextStyle(color: AppColors.textSecondary, fontSize: 16),
|
||||
),
|
||||
)
|
||||
: _buildResultView(context, ref),
|
||||
);
|
||||
}
|
||||
@@ -210,39 +346,42 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])),
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(children: [
|
||||
// 图片自适应显示
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(state.imagePath!),
|
||||
width: screenW - 32,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
child: Column(
|
||||
children: [
|
||||
// 图片自适应显示
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Image.file(
|
||||
File(state.imagePath!),
|
||||
width: screenW - 32,
|
||||
height: 220,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_buildMealSelector(ref),
|
||||
const SizedBox(height: 16),
|
||||
if (state.isAnalyzing)
|
||||
_buildAnalyzing(state)
|
||||
else ...[
|
||||
if (state.foods.isNotEmpty) ...[
|
||||
_buildFoodList(ref),
|
||||
const SizedBox(height: 16),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildMealSelector(ref),
|
||||
const SizedBox(height: 16),
|
||||
if (state.isAnalyzing)
|
||||
_buildAnalyzing(state)
|
||||
else ...[
|
||||
if (state.foods.isNotEmpty) ...[
|
||||
_buildFoodList(ref),
|
||||
const SizedBox(height: 16),
|
||||
_buildAiCommentary(state.commentary!),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null &&
|
||||
state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildAiCommentary(state.commentary!),
|
||||
],
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildSaveButton(),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -258,7 +397,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: meals.map((m) {
|
||||
final sel = state.mealType == m.$3;
|
||||
@@ -272,11 +416,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
color: sel ? _dietAccentLight : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text(m.$1, style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: 2),
|
||||
Text(m.$2, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: sel ? _dietAccent : AppColors.textHint)),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(m.$1, style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
m.$2,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: sel ? _dietAccent : AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -289,48 +443,110 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
Widget _buildAnalyzing(DietState state) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 40),
|
||||
child: Column(children: [
|
||||
const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3, color: _dietAccent)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('正在识别食物...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(state.errorMessage!, style: const TextStyle(fontSize: 14, color: AppColors.error), textAlign: TextAlign.center),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
width: 48,
|
||||
height: 48,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 3,
|
||||
color: _dietAccent,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'正在识别食物...',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
state.errorMessage!,
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.error),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── 食物列表 ───────────
|
||||
Widget _buildFoodList(WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories);
|
||||
final totalCal = state.foods
|
||||
.where((f) => f.selected)
|
||||
.fold(0, (s, f) => s + f.calories);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: AppColors.cardShadowLight),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: _dietAccent, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)),
|
||||
child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: _dietAccent,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'识别结果',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
'共 $totalCal kcal',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
...state.foods.map((food) => _foodItemTile(ref, food)),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||
icon: const Icon(Icons.add_circle_outline, size: 20, color: _dietAccent),
|
||||
label: const Text('添加食物', style: TextStyle(fontSize: 15, color: _dietAccent)),
|
||||
const SizedBox(height: 10),
|
||||
...state.foods.map((food) => _foodItemTile(ref, food)),
|
||||
const SizedBox(height: 4),
|
||||
Center(
|
||||
child: TextButton.icon(
|
||||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||
icon: const Icon(
|
||||
Icons.add_circle_outline,
|
||||
size: 20,
|
||||
color: _dietAccent,
|
||||
),
|
||||
label: const Text(
|
||||
'添加食物',
|
||||
style: TextStyle(fontSize: 15, color: _dietAccent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -341,103 +557,270 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.background,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: food.selected ? _dietAccent.withAlpha(60) : AppColors.borderLight),
|
||||
border: Border.all(
|
||||
color: food.selected
|
||||
? _dietAccent.withAlpha(60)
|
||||
: AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||
child: Container(
|
||||
width: 22, height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: food.selected ? _dietAccent : Colors.white,
|
||||
border: Border.all(color: food.selected ? _dietAccent : AppColors.border, width: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||
child: Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: food.selected ? _dietAccent : Colors.white,
|
||||
border: Border.all(
|
||||
color: food.selected ? _dietAccent : AppColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: food.selected
|
||||
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_compactField(food.name, (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Row(children: [
|
||||
Expanded(flex: 3, child: _compactField(food.portion, (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), hint: '份量', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(flex: 2, child: Row(children: [
|
||||
Expanded(child: _compactField(food.calories.toString(), (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), hint: '0', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFE68A00)), align: TextAlign.right, keyboardType: TextInputType.number)),
|
||||
const SizedBox(width: 2),
|
||||
const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
])),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
|
||||
),
|
||||
]),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_compactField(
|
||||
food.name,
|
||||
(v) => ref
|
||||
.read(dietProvider.notifier)
|
||||
.updateFoodName(food.id, v),
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _compactField(
|
||||
food.portion,
|
||||
(v) => ref
|
||||
.read(dietProvider.notifier)
|
||||
.updateFoodPortion(food.id, v),
|
||||
hint: '份量',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _compactField(
|
||||
food.calories.toString(),
|
||||
(v) => ref
|
||||
.read(dietProvider.notifier)
|
||||
.updateFoodCalories(
|
||||
food.id,
|
||||
int.tryParse(v) ?? 0,
|
||||
),
|
||||
hint: '0',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
align: TextAlign.right,
|
||||
keyboardType: TextInputType.number,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
const Text(
|
||||
'kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||
child: const Icon(Icons.close, size: 18, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _compactField(String value, ValueChanged<String> cb, {String? hint, TextStyle? style, TextAlign align = TextAlign.start, TextInputType? keyboardType}) {
|
||||
Widget _compactField(
|
||||
String value,
|
||||
ValueChanged<String> cb, {
|
||||
String? hint,
|
||||
TextStyle? style,
|
||||
TextAlign align = TextAlign.start,
|
||||
TextInputType? keyboardType,
|
||||
}) {
|
||||
return TextField(
|
||||
controller: TextEditingController(text: value),
|
||||
onChanged: cb,
|
||||
keyboardType: keyboardType,
|
||||
textAlign: align,
|
||||
style: style ?? const TextStyle(fontSize: 15),
|
||||
decoration: InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
decoration: InputDecoration(
|
||||
isDense: true,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── 营养摘要 ───────────
|
||||
Widget _buildNutritionCard(WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories);
|
||||
final totalCal = state.foods
|
||||
.where((f) => f.selected)
|
||||
.fold(0, (s, f) => s + f.calories);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF8F0),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFFE8D0)),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
height: 56,
|
||||
child: CircularProgressIndicator(
|
||||
value: (totalCal / 700).clamp(0.0, 1.0),
|
||||
strokeWidth: 4,
|
||||
backgroundColor: AppColors.borderLight,
|
||||
color: _dietAccent,
|
||||
),
|
||||
),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'$totalCal',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: _dietAccent,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
'kcal',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'本餐热量',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
_macro('碳水', 0.55, const Color(0xFFF5A623)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(children: [
|
||||
SizedBox(
|
||||
width: 56, height: 56,
|
||||
child: Stack(alignment: Alignment.center, children: [
|
||||
SizedBox(width: 56, height: 56, child: CircularProgressIndicator(value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: const Color(0xFFFFE8D0), color: _dietAccent)),
|
||||
Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('$totalCal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent)),
|
||||
const Text('kcal', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 6),
|
||||
Row(children: [
|
||||
_macro('碳水', 0.55, const Color(0xFFF5A623)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('蛋白', 0.25, const Color(0xFF4A90D9)),
|
||||
const SizedBox(width: 8),
|
||||
_macro('脂肪', 0.20, const Color(0xFFE8686A)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _macro(String label, double ratio, Color color) {
|
||||
return Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))]),
|
||||
const SizedBox(height: 3),
|
||||
ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color)),
|
||||
]),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
child: LinearProgressIndicator(
|
||||
value: ratio,
|
||||
minHeight: 4,
|
||||
backgroundColor: AppColors.borderLight,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -448,17 +831,33 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBF5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFFFE8C0)),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
color: _dietAccentLight,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Container(
|
||||
width: 36, height: 36,
|
||||
decoration: BoxDecoration(color: _dietAccentLight, borderRadius: BorderRadius.circular(10)),
|
||||
child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.6))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -472,14 +871,21 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
try {
|
||||
await ref.read(dietProvider.notifier).saveRecord();
|
||||
if (mounted) popRoute(ref);
|
||||
} catch (e) { debugPrint('[Diet] 保存记录失败: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('[Diet] 保存记录失败: $e');
|
||||
}
|
||||
},
|
||||
icon: const Icon(Icons.check_circle_outline, size: 22),
|
||||
label: const Text('保存记录', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
|
||||
label: const Text(
|
||||
'保存记录',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: _dietAccent,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -4,7 +4,9 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
||||
ref,
|
||||
) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/consultations');
|
||||
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
@@ -12,35 +14,61 @@ final _consListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async
|
||||
|
||||
class DoctorConsultationsPage extends ConsumerWidget {
|
||||
const DoctorConsultationsPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final list = ref.watch(_consListProvider);
|
||||
return list.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = items[i];
|
||||
final status = c['status'] ?? '';
|
||||
final msg = c['lastMessage'] as Map<String, dynamic>?;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Text((c['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))),
|
||||
title: Text(c['patientName'] ?? c['patientPhone'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text(msg?['content'] ?? '暂无消息', maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
trailing: _StatusBadge(status),
|
||||
onTap: () => pushRoute(ref, 'doctorChat', params: {'id': c['id']?.toString() ?? ''}),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
? const Center(
|
||||
child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final c = items[i];
|
||||
final status = c['status'] ?? '';
|
||||
final msg = c['lastMessage'] as Map<String, dynamic>?;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(c['patientName'] ?? '?')[0],
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
c['patientName'] ?? c['patientPhone'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
msg?['content'] ?? '暂无消息',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: _StatusBadge(status),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorChat',
|
||||
params: {'id': c['id']?.toString() ?? ''},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -48,14 +76,29 @@ class DoctorConsultationsPage extends ConsumerWidget {
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String status;
|
||||
const _StatusBadge(this.status);
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = switch (status) {
|
||||
'AiTalking' => ('AI中', const Color(0xFF6366F1)),
|
||||
'AiTalking' => ('AI中', AppColors.primary),
|
||||
'WaitingDoctor' => ('等待医生', const Color(0xFFF59E0B)),
|
||||
'DoctorReplied' => ('已回复', const Color(0xFF10B981)),
|
||||
'Closed' => ('已关闭', const Color(0xFF9CA3AF)),
|
||||
_ => (status, const Color(0xFF9CA3AF)),
|
||||
};
|
||||
return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color, fontWeight: FontWeight.w500)));
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,16 +27,38 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(color: const Color(0xFFE0F2FE), borderRadius: BorderRadius.circular(12)),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.info_outline, color: Color(0xFF0891B2), size: 20),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(child: Text('请完善个人信息', style: TextStyle(color: Color(0xFF0891B2), fontSize: 14))),
|
||||
GestureDetector(
|
||||
onTap: () => {},
|
||||
child: const Text('去完善', style: TextStyle(color: Color(0xFF0891B2), fontWeight: FontWeight.w600)),
|
||||
),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.infoLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.info_outline,
|
||||
color: AppColors.primary,
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'请完善个人信息',
|
||||
style: TextStyle(color: AppColors.primary, fontSize: 14),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => {},
|
||||
child: const Text(
|
||||
'去完善',
|
||||
style: TextStyle(
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
dash.when(
|
||||
@@ -49,71 +71,122 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildContent(BuildContext context, WidgetRef ref, Map<String, dynamic>? data) {
|
||||
Widget _buildContent(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
Map<String, dynamic>? data,
|
||||
) {
|
||||
final stats = data?['stats'] as Map<String, dynamic>? ?? {};
|
||||
|
||||
return Column(children: [
|
||||
// 统计卡片
|
||||
Row(children: [
|
||||
Expanded(child: _StatCard('患者总数', '${stats['totalPatients'] ?? 0}', Icons.people, const Color(0xFF3B82F6), () {
|
||||
ref.read(doctorPageProvider.notifier).set('patients');
|
||||
})),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _StatCard('进行中问诊', '${stats['activeConsultations'] ?? 0}', Icons.chat, const Color(0xFF10B981), () {
|
||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||
})),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
Row(children: [
|
||||
Expanded(child: _StatCard('待审核报告', '${stats['pendingReports'] ?? 0}', Icons.description, const Color(0xFFF59E0B), () {
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
})),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: _StatCard('今日随访', '${stats['todayFollowUps'] ?? 0}', Icons.event_note, const Color(0xFFEF4444), () {
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
})),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
return Column(
|
||||
children: [
|
||||
// 统计卡片
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
'患者总数',
|
||||
'${stats['totalPatients'] ?? 0}',
|
||||
Icons.people,
|
||||
const Color(0xFF3B82F6),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('patients');
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
'进行中问诊',
|
||||
'${stats['activeConsultations'] ?? 0}',
|
||||
Icons.chat,
|
||||
const Color(0xFF10B981),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
'待审核报告',
|
||||
'${stats['pendingReports'] ?? 0}',
|
||||
Icons.description,
|
||||
const Color(0xFFF59E0B),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _StatCard(
|
||||
'今日随访',
|
||||
'${stats['todayFollowUps'] ?? 0}',
|
||||
Icons.event_note,
|
||||
const Color(0xFFEF4444),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 待办:未回复问诊
|
||||
_TodoSection(
|
||||
title: '待回复问诊',
|
||||
icon: Icons.chat_outlined,
|
||||
color: const Color(0xFF10B981),
|
||||
items: (data?['pendingConsultations'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
||||
itemLabel: (m) => m['patientName'] ?? '',
|
||||
itemSubtitle: (m) => '待回复',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||
},
|
||||
),
|
||||
// 待办:未回复问诊
|
||||
_TodoSection(
|
||||
title: '待回复问诊',
|
||||
icon: Icons.chat_outlined,
|
||||
color: const Color(0xFF10B981),
|
||||
items:
|
||||
(data?['pendingConsultations'] as List?)
|
||||
?.cast<Map<String, dynamic>>() ??
|
||||
[],
|
||||
itemLabel: (m) => m['patientName'] ?? '',
|
||||
itemSubtitle: (m) => '待回复',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('consultations');
|
||||
},
|
||||
),
|
||||
|
||||
// 待办:待审核报告
|
||||
_TodoSection(
|
||||
title: '待审核报告',
|
||||
icon: Icons.description_outlined,
|
||||
color: const Color(0xFFF59E0B),
|
||||
items: (data?['pendingReports'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
||||
itemLabel: (m) => '${m['patientName'] ?? ''}',
|
||||
itemSubtitle: (m) => '${m['category'] ?? ''}',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
},
|
||||
),
|
||||
// 待办:待审核报告
|
||||
_TodoSection(
|
||||
title: '待审核报告',
|
||||
icon: Icons.description_outlined,
|
||||
color: const Color(0xFFF59E0B),
|
||||
items:
|
||||
(data?['pendingReports'] as List?)
|
||||
?.cast<Map<String, dynamic>>() ??
|
||||
[],
|
||||
itemLabel: (m) => '${m['patientName'] ?? ''}',
|
||||
itemSubtitle: (m) => '${m['category'] ?? ''}',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
},
|
||||
),
|
||||
|
||||
// 今日随访
|
||||
_TodoSection(
|
||||
title: '今日随访',
|
||||
icon: Icons.event_note_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
items: (data?['todayFollowUps'] as List?)?.cast<Map<String, dynamic>>() ?? [],
|
||||
itemLabel: (m) => '${m['title'] ?? ''}',
|
||||
itemSubtitle: (m) => '${m['patientName'] ?? ''}',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
},
|
||||
),
|
||||
]);
|
||||
// 今日随访
|
||||
_TodoSection(
|
||||
title: '今日随访',
|
||||
icon: Icons.event_note_outlined,
|
||||
color: const Color(0xFFEF4444),
|
||||
items:
|
||||
(data?['todayFollowUps'] as List?)
|
||||
?.cast<Map<String, dynamic>>() ??
|
||||
[],
|
||||
itemLabel: (m) => '${m['title'] ?? ''}',
|
||||
itemSubtitle: (m) => '${m['patientName'] ?? ''}',
|
||||
onTap: (m) {
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,24 +199,52 @@ class _StatCard extends StatelessWidget {
|
||||
|
||||
const _StatCard(this.label, this.value, this.icon, this.color, this.onTap);
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(10)),
|
||||
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)),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
]),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -158,38 +259,98 @@ class _TodoSection extends StatelessWidget {
|
||||
final String Function(Map<String, dynamic>) itemSubtitle;
|
||||
final void Function(Map<String, dynamic>) onTap;
|
||||
|
||||
const _TodoSection({required this.title, required this.icon, required this.color, required this.items, required this.itemLabel, required this.itemSubtitle, required this.onTap});
|
||||
const _TodoSection({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.items,
|
||||
required this.itemLabel,
|
||||
required this.itemSubtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (items.isEmpty) return const SizedBox.shrink();
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const Spacer(),
|
||||
Text('${items.length}项', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
...items.take(5).map((m) => GestureDetector(
|
||||
onTap: () => onTap(m),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: const BoxDecoration(border: Border(top: BorderSide(color: Color(0xFFF5F5F5)))),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(itemLabel(m), style: const TextStyle(fontSize: 14, color: AppColors.textPrimary))),
|
||||
Text(itemSubtitle(m), style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 16, color: AppColors.textHint),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${items.length}项',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
...items
|
||||
.take(5)
|
||||
.map(
|
||||
(m) => GestureDetector(
|
||||
onTap: () => onTap(m),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(
|
||||
top: BorderSide(color: AppColors.divider),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
itemLabel(m),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
itemSubtitle(m),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 16,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -13,10 +13,13 @@ final _ptsSimple = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
class DoctorFollowUpEditPage extends ConsumerStatefulWidget {
|
||||
final String? id;
|
||||
const DoctorFollowUpEditPage({super.key, this.id});
|
||||
@override ConsumerState<DoctorFollowUpEditPage> createState() => _DoctorFollowUpEditPageState();
|
||||
@override
|
||||
ConsumerState<DoctorFollowUpEditPage> createState() =>
|
||||
_DoctorFollowUpEditPageState();
|
||||
}
|
||||
|
||||
class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage> {
|
||||
class _DoctorFollowUpEditPageState
|
||||
extends ConsumerState<DoctorFollowUpEditPage> {
|
||||
final _titleCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
String? _pid;
|
||||
@@ -27,9 +30,15 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
||||
|
||||
bool get isEdit => widget.id != null && widget.id!.isNotEmpty;
|
||||
|
||||
@override void dispose() { _titleCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
void dispose() {
|
||||
_titleCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isEdit && !_loaded) {
|
||||
ref.watch(_fupDetailForEdit(widget.id!)).whenData((d) {
|
||||
if (d != null) {
|
||||
@@ -38,7 +47,10 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
||||
_notesCtrl.text = d['notes'] ?? '';
|
||||
_pid = d['userId']?.toString();
|
||||
final at = DateTime.tryParse(d['scheduledAt']?.toString() ?? '');
|
||||
if (at != null) { _date = at; _time = TimeOfDay.fromDateTime(at); }
|
||||
if (at != null) {
|
||||
_date = at;
|
||||
_time = TimeOfDay.fromDateTime(at);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -46,114 +58,245 @@ class _DoctorFollowUpEditPageState extends ConsumerState<DoctorFollowUpEditPage>
|
||||
final pts = ref.watch(_ptsSimple);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white, elevation: 0,
|
||||
title: Text(isEdit ? '编辑随访' : '新建随访', style: const TextStyle(color: AppColors.textPrimary)),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
||||
),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
const Text('患者', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
pts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (list) => _dropdown(list),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
isEdit ? '编辑随访' : '新建随访',
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
|
||||
const SizedBox(height: 16),
|
||||
const Text('随访时间', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
_dateTimePicker(),
|
||||
const SizedBox(height: 16),
|
||||
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
|
||||
const SizedBox(height: 28),
|
||||
_submitBtn(),
|
||||
]),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
const Text(
|
||||
'患者',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
pts.when(
|
||||
loading: () => const LinearProgressIndicator(),
|
||||
error: (_, __) => const Text('加载失败'),
|
||||
data: (list) => _dropdown(list),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_input('随访标题', _titleCtrl, hint: '例:术后一个月复查'),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'随访时间',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_dateTimePicker(),
|
||||
const SizedBox(height: 16),
|
||||
_input('备注', _notesCtrl, hint: '随访备注(可选)', maxLines: 4),
|
||||
const SizedBox(height: 28),
|
||||
_submitBtn(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _dropdown(List<Map<String, dynamic>> list) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: DropdownButtonHideUnderline(
|
||||
child: DropdownButton<String?>(
|
||||
isExpanded: true,
|
||||
value: _pid,
|
||||
hint: const Text('选择患者', style: TextStyle(color: AppColors.textHint)),
|
||||
items: [
|
||||
const DropdownMenuItem<String?>(value: null, child: Text('选择患者', style: TextStyle(color: AppColors.textHint))),
|
||||
...list.map((p) => DropdownMenuItem<String?>(value: p['id']?.toString(), child: Text('${p['name'] ?? p['phone']} (${p['phone']})', style: const TextStyle(fontSize: 15)))),
|
||||
const DropdownMenuItem<String?>(
|
||||
value: null,
|
||||
child: Text('选择患者', style: TextStyle(color: AppColors.textHint)),
|
||||
),
|
||||
...list.map(
|
||||
(p) => DropdownMenuItem<String?>(
|
||||
value: p['id']?.toString(),
|
||||
child: Text(
|
||||
'${p['name'] ?? p['phone']} (${p['phone']})',
|
||||
style: const TextStyle(fontSize: 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
onChanged: (v) => setState(() { _pid = v; }),
|
||||
onChanged: (v) => setState(() {
|
||||
_pid = v;
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Widget _input(String label, TextEditingController ctrl, {String? hint, int maxLines = 1}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: TextField(controller: ctrl, maxLines: maxLines, decoration: InputDecoration(hintText: hint, border: InputBorder.none)),
|
||||
),
|
||||
]);
|
||||
Widget _input(
|
||||
String label,
|
||||
TextEditingController ctrl, {
|
||||
String? hint,
|
||||
int maxLines = 1,
|
||||
}) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
maxLines: maxLines,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _dateTimePicker() => Row(children: [
|
||||
Expanded(child: GestureDetector(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(context: context, initialDate: _date, firstDate: DateTime.now(), lastDate: DateTime.now().add(const Duration(days: 365)));
|
||||
if (d != null) setState(() => _date = d);
|
||||
},
|
||||
child: _pickerBox('${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}'),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: GestureDetector(
|
||||
onTap: () async {
|
||||
final t = await showTimePicker(context: context, initialTime: _time);
|
||||
if (t != null) setState(() => _time = t);
|
||||
},
|
||||
child: _pickerBox('${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}'),
|
||||
)),
|
||||
]);
|
||||
Widget _dateTimePicker() => Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: _date,
|
||||
firstDate: DateTime.now(),
|
||||
lastDate: DateTime.now().add(const Duration(days: 365)),
|
||||
);
|
||||
if (d != null) setState(() => _date = d);
|
||||
},
|
||||
child: _pickerBox(
|
||||
'${_date.year}-${_date.month.toString().padLeft(2, '0')}-${_date.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _time,
|
||||
);
|
||||
if (t != null) setState(() => _time = t);
|
||||
},
|
||||
child: _pickerBox(
|
||||
'${_time.hour.toString().padLeft(2, '0')}:${_time.minute.toString().padLeft(2, '0')}',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _pickerBox(String text) => Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Center(child: Text(text, style: const TextStyle(fontSize: 15))),
|
||||
);
|
||||
|
||||
Widget _submitBtn() => SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
||||
child: _saving ? const CircularProgressIndicator(color: Colors.white) : Text(isEdit ? '保存修改' : '创建随访', style: const TextStyle(fontSize: 16)),
|
||||
));
|
||||
Widget _submitBtn() => SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(
|
||||
isEdit ? '保存修改' : '创建随访',
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _save() async {
|
||||
if (_pid == null) { _snack('请选择患者'); return; }
|
||||
if (_titleCtrl.text.trim().isEmpty) { _snack('请输入随访标题'); return; }
|
||||
if (_pid == null) {
|
||||
_snack('请选择患者');
|
||||
return;
|
||||
}
|
||||
if (_titleCtrl.text.trim().isEmpty) {
|
||||
_snack('请输入随访标题');
|
||||
return;
|
||||
}
|
||||
setState(() => _saving = true);
|
||||
final at = DateTime(_date.year, _date.month, _date.day, _time.hour, _time.minute);
|
||||
final at = DateTime(
|
||||
_date.year,
|
||||
_date.month,
|
||||
_date.day,
|
||||
_time.hour,
|
||||
_time.minute,
|
||||
);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final data = {'userId': _pid, 'title': _titleCtrl.text.trim(), 'scheduledAt': at.toIso8601String(), 'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim()};
|
||||
if (isEdit) { await api.put('/api/doctor/follow-ups/${widget.id}', data: data); }
|
||||
else { await api.post('/api/doctor/follow-ups', data: data); }
|
||||
if (mounted) { _snack(isEdit ? '修改成功' : '创建成功', ok: true); popRoute(ref); }
|
||||
} catch (e) { if (mounted) _snack('保存失败: $e'); }
|
||||
finally { if (mounted) setState(() => _saving = false); }
|
||||
final data = {
|
||||
'userId': _pid,
|
||||
'title': _titleCtrl.text.trim(),
|
||||
'scheduledAt': at.toIso8601String(),
|
||||
'notes': _notesCtrl.text.trim().isEmpty ? null : _notesCtrl.text.trim(),
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.put('/api/doctor/follow-ups/${widget.id}', data: data);
|
||||
} else {
|
||||
await api.post('/api/doctor/follow-ups', data: data);
|
||||
}
|
||||
if (mounted) {
|
||||
_snack(isEdit ? '修改成功' : '创建成功', ok: true);
|
||||
popRoute(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) _snack('保存失败: $e');
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _snack(String msg, {bool ok = false}) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg), backgroundColor: ok ? AppColors.success : AppColors.error));
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(msg),
|
||||
backgroundColor: ok ? AppColors.success : AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
|
||||
ref,
|
||||
id,
|
||||
) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/follow-ups');
|
||||
final items = (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
return items.cast<Map<String, dynamic>?>().firstWhere((f) => f?['id']?.toString() == id, orElse: () => null);
|
||||
return items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(f) => f?['id']?.toString() == id,
|
||||
orElse: () => null,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -12,100 +12,253 @@ final _fupRefresh = FutureProvider<String?>((ref) async {
|
||||
return null;
|
||||
});
|
||||
|
||||
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(FupListN.new);
|
||||
final _fupList = NotifierProvider<FupListN, List<Map<String, dynamic>>>(
|
||||
FupListN.new,
|
||||
);
|
||||
|
||||
class FupListN extends Notifier<List<Map<String, dynamic>>> {
|
||||
@override List<Map<String, dynamic>> build() => [];
|
||||
@override
|
||||
List<Map<String, dynamic>> build() => [];
|
||||
void replace(List<Map<String, dynamic>> v) => state = v;
|
||||
void markDone(String id) => state = state.map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f).toList();
|
||||
void markDone(String id) => state = state
|
||||
.map((f) => f['id'] == id ? {...f, 'status': 'Completed'} : f)
|
||||
.toList();
|
||||
void remove(String id) => state = state.where((f) => f['id'] != id).toList();
|
||||
}
|
||||
|
||||
class DoctorFollowupsPage extends ConsumerStatefulWidget {
|
||||
const DoctorFollowupsPage({super.key});
|
||||
@override ConsumerState<DoctorFollowupsPage> createState() => _DoctorFollowupsPageState();
|
||||
@override
|
||||
ConsumerState<DoctorFollowupsPage> createState() =>
|
||||
_DoctorFollowupsPageState();
|
||||
}
|
||||
|
||||
class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
String _filter = '';
|
||||
|
||||
@override void initState() { super.initState(); Future.microtask(() => ref.read(_fupRefresh)); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(_fupRefresh));
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var items = ref.watch(_fupList);
|
||||
if (_filter == 'Upcoming') items = items.where((f) => f['status'] == 'Upcoming').toList();
|
||||
else if (_filter == 'Completed') items = items.where((f) => f['status'] == 'Completed').toList();
|
||||
if (_filter == 'Upcoming')
|
||||
items = items.where((f) => f['status'] == 'Upcoming').toList();
|
||||
else if (_filter == 'Completed')
|
||||
items = items.where((f) => f['status'] == 'Completed').toList();
|
||||
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(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')),
|
||||
]),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_fupRefresh.future),
|
||||
child: items.isEmpty
|
||||
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无随访', style: TextStyle(color: AppColors.textHint)))])
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = items[i];
|
||||
final upcoming = f['status'] == 'Upcoming';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Expanded(child: Text(f['title'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 15))),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: (upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)).withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(upcoming ? '待完成' : '已完成', style: TextStyle(fontSize: 12, color: upcoming ? const Color(0xFFF59E0B) : const Color(0xFF10B981)))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text('${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
if (f['notes'] != null) Padding(padding: const EdgeInsets.only(top: 4), child: Text(f['notes']!, style: const TextStyle(fontSize: 13, color: AppColors.textSecondary))),
|
||||
if (upcoming) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(mainAxisAlignment: MainAxisAlignment.end, children: [
|
||||
TextButton(onPressed: () => pushRoute(ref, 'doctorFollowUpEdit', params: {'id': f['id']?.toString() ?? ''}), child: const Text('编辑', style: TextStyle(fontSize: 13))),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(apiClientProvider).put('/api/doctor/follow-ups/${f['id']}', data: {'status': 'Completed'});
|
||||
ref.read(_fupList.notifier).markDone(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text('完成', style: TextStyle(fontSize: 13, color: Color(0xFF10B981))),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref.read(apiClientProvider).delete('/api/doctor/follow-ups/${f['id']}');
|
||||
ref.read(_fupList.notifier).remove(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text('删除', style: TextStyle(fontSize: 13, color: AppColors.error)),
|
||||
),
|
||||
]),
|
||||
],
|
||||
]),
|
||||
);
|
||||
},
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_fupRefresh.future),
|
||||
child: items.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无随访',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final f = items[i];
|
||||
final upcoming = f['status'] == 'Upcoming';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
f['title'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
(upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success)
|
||||
.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
upcoming ? '待完成' : '已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: upcoming
|
||||
? AppColors.warning
|
||||
: AppColors.success,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${f['patientName'] ?? ''} · ${(f['scheduledAt'] ?? '').toString().substring(0, 16)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
if (f['notes'] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
f['notes']!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (upcoming) ...[
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => pushRoute(
|
||||
ref,
|
||||
'doctorFollowUpEdit',
|
||||
params: {'id': f['id']?.toString() ?? ''},
|
||||
),
|
||||
child: const Text(
|
||||
'编辑',
|
||||
style: TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.put(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
data: {'status': 'Completed'},
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.markDone(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text(
|
||||
'完成',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.delete(
|
||||
'/api/doctor/follow-ups/${f['id']}',
|
||||
);
|
||||
ref
|
||||
.read(_fupList.notifier)
|
||||
.remove(f['id']?.toString() ?? '');
|
||||
},
|
||||
child: const Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Filt extends StatelessWidget {
|
||||
final String label; final bool active; final VoidCallback onTap;
|
||||
final String label;
|
||||
final bool active;
|
||||
final VoidCallback onTap;
|
||||
const _Filt(this.label, this.active, this.onTap);
|
||||
@override Widget build(BuildContext context) => GestureDetector(
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))), child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary))),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: active ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../widgets/doctor_drawer.dart';
|
||||
import 'doctor_dashboard_page.dart';
|
||||
import 'doctor_patients_page.dart';
|
||||
@@ -22,11 +21,15 @@ class DoctorHomePage extends ConsumerWidget {
|
||||
if (!didPop) ref.read(doctorPageProvider.notifier).set('dashboard');
|
||||
},
|
||||
child: Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
title: Text(_titleFor(page), style: const TextStyle(color: AppColors.textPrimary)),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: Text(
|
||||
_titleFor(page),
|
||||
style: const TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: Builder(
|
||||
builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu, color: AppColors.textPrimary),
|
||||
@@ -59,9 +62,12 @@ class DoctorHomePage extends ConsumerWidget {
|
||||
};
|
||||
}
|
||||
|
||||
final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(DoctorPageNotifier.new);
|
||||
final doctorPageProvider = NotifierProvider<DoctorPageNotifier, String>(
|
||||
DoctorPageNotifier.new,
|
||||
);
|
||||
|
||||
class DoctorPageNotifier extends Notifier<String> {
|
||||
@override String build() => 'dashboard';
|
||||
@override
|
||||
String build() => 'dashboard';
|
||||
void set(String page) => state = page;
|
||||
}
|
||||
|
||||
@@ -4,27 +4,41 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final _patientDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/patients/$id');
|
||||
return res.data['data'] as Map<String, dynamic>?;
|
||||
});
|
||||
final _patientDetailProvider =
|
||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/patients/$id');
|
||||
return res.data['data'] as Map<String, dynamic>?;
|
||||
});
|
||||
|
||||
class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
final String id;
|
||||
const DoctorPatientDetailPage({super.key, required this.id});
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final detail = ref.watch(_patientDetailProvider(id));
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('患者详情', style: TextStyle(color: AppColors.textPrimary)),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
'患者详情',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (data) => data == null ? const Center(child: Text('患者不存在')) : _buildBody(data),
|
||||
data: (data) => data == null
|
||||
? const Center(child: Text('患者不存在'))
|
||||
: _buildBody(data),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -40,95 +54,241 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
final diet = data['dietRecords'] as List? ?? [];
|
||||
final exercise = data['exercisePlan'] as Map<String, dynamic>?;
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
||||
// 基本信息卡片
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(children: [
|
||||
CircleAvatar(radius: 28, backgroundColor: const Color(0xFFF0F0FF), child: Text((profile['name'] ?? '患')[0], style: const TextStyle(fontSize: 22, color: AppColors.primary))),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(profile['name'] ?? '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700)),
|
||||
Text(profile['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
if (profile['gender'] != null) Text('${profile['gender']} · ${profile['birthDate'] ?? ''}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 健康档案
|
||||
if (archive != null) ...[
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 基本信息卡片
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('健康档案', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
if (archive['diagnosis'] != null) _row('诊断', archive['diagnosis']),
|
||||
if (archive['surgeryType'] != null) _row('手术史', '${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}'),
|
||||
if (archive['allergies'] != null && (archive['allergies'] as List).isNotEmpty) _row('过敏史', (archive['allergies'] as List).join('、')),
|
||||
if (archive['chronicDiseases'] != null && (archive['chronicDiseases'] as List).isNotEmpty) _row('慢病', (archive['chronicDiseases'] as List).join('、')),
|
||||
if (archive['familyHistory'] != null) _row('家族史', archive['familyHistory']),
|
||||
]),
|
||||
decoration: _detailCardDecoration(),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(profile['name'] ?? '患')[0],
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
profile['name'] ?? '未设置',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
profile['phone'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
if (profile['gender'] != null)
|
||||
Text(
|
||||
'${profile['gender']} · ${profile['birthDate'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 最新健康指标
|
||||
if (latest.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('健康指标', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
...latest.map((r) {
|
||||
final type = r['metricType']?.toString() ?? '';
|
||||
String val = '';
|
||||
if (type == 'BloodPressure') val = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
|
||||
else val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}';
|
||||
return Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(children: [
|
||||
Text(_metricLabel(type), style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
const Spacer(),
|
||||
Text(val, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: r['isAbnormal'] == true ? AppColors.error : AppColors.textPrimary)),
|
||||
]));
|
||||
}),
|
||||
]),
|
||||
// 健康档案
|
||||
if (archive != null) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _detailCardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'健康档案',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (archive['diagnosis'] != null)
|
||||
_row('诊断', archive['diagnosis']),
|
||||
if (archive['surgeryType'] != null)
|
||||
_row(
|
||||
'手术史',
|
||||
'${archive['surgeryType']} ${archive['surgeryDate'] ?? ''}',
|
||||
),
|
||||
if (archive['allergies'] != null &&
|
||||
(archive['allergies'] as List).isNotEmpty)
|
||||
_row('过敏史', (archive['allergies'] as List).join('、')),
|
||||
if (archive['chronicDiseases'] != null &&
|
||||
(archive['chronicDiseases'] as List).isNotEmpty)
|
||||
_row('慢病', (archive['chronicDiseases'] as List).join('、')),
|
||||
if (archive['familyHistory'] != null)
|
||||
_row('家族史', archive['familyHistory']),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 最新健康指标
|
||||
if (latest.isNotEmpty) ...[
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _detailCardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'健康指标',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...latest.map((r) {
|
||||
final type = r['metricType']?.toString() ?? '';
|
||||
String val = '';
|
||||
if (type == 'BloodPressure')
|
||||
val =
|
||||
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
|
||||
else
|
||||
val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_metricLabel(type),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
val,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: r['isAbnormal'] == true
|
||||
? AppColors.error
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 用药列表
|
||||
if (meds.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _detailCardDecoration(),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'当前用药',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...meds.map(
|
||||
(m) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text(
|
||||
'${m['name']} ${m['dosage'] ?? ''} ${_freqLabel(m['frequency'] ?? '')}',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 报告和随访入口
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _LinkCard(
|
||||
'报告 (${reports.length})',
|
||||
Icons.description_outlined,
|
||||
() {},
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _LinkCard(
|
||||
'随访 (${followUps.length})',
|
||||
Icons.event_note_outlined,
|
||||
() {},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
],
|
||||
|
||||
// 用药列表
|
||||
if (meds.isNotEmpty)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('当前用药', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
...meds.map((m) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Text('${m['name']} ${m['dosage'] ?? ''} ${_freqLabel(m['frequency'] ?? '')}', style: const TextStyle(fontSize: 14)))),
|
||||
]),
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 报告和随访入口
|
||||
Row(children: [
|
||||
Expanded(child: _LinkCard('报告 (${reports.length})', Icons.description_outlined, () {})),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _LinkCard('随访 (${followUps.length})', Icons.event_note_outlined, () {})),
|
||||
]),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
Widget _row(String label, String? value) => Padding(padding: const EdgeInsets.only(bottom: 4), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
|
||||
Expanded(child: Text(value ?? '', style: const TextStyle(fontSize: 14))),
|
||||
]));
|
||||
Widget _row(String label, String? value) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(value ?? '', style: const TextStyle(fontSize: 14)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
String _metricLabel(String t) => switch (t) { 'BloodPressure' => '血压', 'HeartRate' => '心率', 'Glucose' => '血糖', 'SpO2' => '血氧', 'Weight' => '体重', _ => t };
|
||||
String _freqLabel(String f) => switch (f) { 'Daily' => '每日', 'Weekly' => '每周', 'Monthly' => '每月', 'AsNeeded' => '按需', _ => f };
|
||||
BoxDecoration _detailCardDecoration() => BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
);
|
||||
|
||||
String _metricLabel(String t) => switch (t) {
|
||||
'BloodPressure' => '血压',
|
||||
'HeartRate' => '心率',
|
||||
'Glucose' => '血糖',
|
||||
'SpO2' => '血氧',
|
||||
'Weight' => '体重',
|
||||
_ => t,
|
||||
};
|
||||
String _freqLabel(String f) => switch (f) {
|
||||
'Daily' => '每日',
|
||||
'Weekly' => '每周',
|
||||
'Monthly' => '每月',
|
||||
'AsNeeded' => '按需',
|
||||
_ => f,
|
||||
};
|
||||
}
|
||||
|
||||
class _LinkCard extends StatelessWidget {
|
||||
@@ -136,16 +296,28 @@ class _LinkCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _LinkCard(this.label, this.icon, this.onTap);
|
||||
@override Widget build(BuildContext context) => GestureDetector(
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
Icon(icon, size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, size: 18, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@ import '../../providers/auth_provider.dart';
|
||||
|
||||
class DoctorPatientsPage extends ConsumerStatefulWidget {
|
||||
const DoctorPatientsPage({super.key});
|
||||
@override ConsumerState<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
|
||||
@override
|
||||
ConsumerState<DoctorPatientsPage> createState() => _DoctorPatientsPageState();
|
||||
}
|
||||
|
||||
class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
@@ -17,74 +18,144 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
|
||||
bool _hasMore = true;
|
||||
int _total = 0;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() { _searchCtrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load({bool reset = false}) async {
|
||||
if (_loading) return;
|
||||
if (reset) { _page = 1; _patients.clear(); }
|
||||
if (reset) {
|
||||
_page = 1;
|
||||
_patients.clear();
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final params = <String, dynamic>{'page': _page, 'pageSize': 20};
|
||||
final search = _searchCtrl.text.trim();
|
||||
if (search.isNotEmpty) params['search'] = search;
|
||||
final res = await api.get('/api/doctor/patients', queryParameters: params);
|
||||
final res = await api.get(
|
||||
'/api/doctor/patients',
|
||||
queryParameters: params,
|
||||
);
|
||||
final data = res.data['data'];
|
||||
if (data != null) {
|
||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final items =
|
||||
(data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
setState(() {
|
||||
if (reset) _patients = items; else _patients.addAll(items);
|
||||
if (reset)
|
||||
_patients = items;
|
||||
else
|
||||
_patients.addAll(items);
|
||||
_total = data['total'] ?? 0;
|
||||
_hasMore = _patients.length < _total;
|
||||
_page++;
|
||||
});
|
||||
}
|
||||
} catch (_) {} finally {
|
||||
} catch (_) {
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _onSearch() async { await _load(reset: true); }
|
||||
Future<void> _loadMore() async { if (_hasMore) await _load(); }
|
||||
Future<void> _onSearch() async {
|
||||
await _load(reset: true);
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
onSubmitted: (_) => _onSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索患者姓名或手机号',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
||||
suffixIcon: _searchCtrl.text.isNotEmpty ? IconButton(icon: const Icon(Icons.clear), onPressed: () { _searchCtrl.clear(); _onSearch(); }) : null,
|
||||
filled: true, fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
Future<void> _loadMore() async {
|
||||
if (_hasMore) await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
onSubmitted: (_) => _onSearch(),
|
||||
decoration: InputDecoration(
|
||||
hintText: '搜索患者姓名或手机号',
|
||||
prefixIcon: const Icon(Icons.search, color: AppColors.textHint),
|
||||
suffixIcon: _searchCtrl.text.isNotEmpty
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.clear),
|
||||
onPressed: () {
|
||||
_searchCtrl.clear();
|
||||
_onSearch();
|
||||
},
|
||||
)
|
||||
: null,
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: _patients.isEmpty && !_loading
|
||||
? ListView(children: const [SizedBox(height: 100), Center(child: Text('暂无患者数据', style: TextStyle(color: AppColors.textHint)))])
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _patients.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= _patients.length) {
|
||||
_loadMore();
|
||||
return const Padding(padding: EdgeInsets.all(16), child: Center(child: CircularProgressIndicator(strokeWidth: 2)));
|
||||
}
|
||||
final p = _patients[i];
|
||||
return _PatientTile(p: p, onTap: () => pushRoute(ref, 'doctorPatientDetail', params: {'id': p['id']?.toString() ?? ''}));
|
||||
},
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: () => _load(reset: true),
|
||||
child: _patients.isEmpty && !_loading
|
||||
? ListView(
|
||||
children: const [
|
||||
SizedBox(height: 100),
|
||||
Center(
|
||||
child: Text(
|
||||
'暂无患者数据',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: _patients.length + (_hasMore ? 1 : 0),
|
||||
itemBuilder: (_, i) {
|
||||
if (i >= _patients.length) {
|
||||
_loadMore();
|
||||
return const Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Center(
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
),
|
||||
);
|
||||
}
|
||||
final p = _patients[i];
|
||||
return _PatientTile(
|
||||
p: p,
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorPatientDetail',
|
||||
params: {'id': p['id']?.toString() ?? ''},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,24 +164,58 @@ class _PatientTile extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _PatientTile({required this.p, required this.onTap});
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gender = p['gender'] ?? '';
|
||||
final genderIcon = gender == 'Male' ? Icons.male : gender == 'Female' ? Icons.female : Icons.person;
|
||||
final genderIcon = gender == 'Male'
|
||||
? Icons.male
|
||||
: gender == 'Female'
|
||||
? Icons.female
|
||||
: Icons.person;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(children: [
|
||||
CircleAvatar(radius: 22, backgroundColor: const Color(0xFFF0F0FF), child: Icon(genderIcon, color: AppColors.primary)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(p['name'] ?? p['phone'] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
Text(p['phone'] ?? '', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
])),
|
||||
const Icon(Icons.chevron_right, color: AppColors.textHint),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 22,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Icon(genderIcon, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
p['name'] ?? p['phone'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
p['phone'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(Icons.chevron_right, color: AppColors.textHint),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,9 @@ final _docProfileProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
|
||||
class DoctorProfileEditPage extends ConsumerStatefulWidget {
|
||||
const DoctorProfileEditPage({super.key});
|
||||
@override ConsumerState<DoctorProfileEditPage> createState() => _DoctorProfileEditPageState();
|
||||
@override
|
||||
ConsumerState<DoctorProfileEditPage> createState() =>
|
||||
_DoctorProfileEditPageState();
|
||||
}
|
||||
|
||||
class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
@@ -22,33 +24,73 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
final _hosp = TextEditingController();
|
||||
bool _saving = false;
|
||||
|
||||
@override void dispose() { _name.dispose(); _title.dispose(); _dept.dispose(); _hosp.dispose(); super.dispose(); }
|
||||
@override
|
||||
void dispose() {
|
||||
_name.dispose();
|
||||
_title.dispose();
|
||||
_dept.dispose();
|
||||
_hosp.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final prof = ref.watch(_docProfileProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('个人信息', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
'个人信息',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: prof.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (d) {
|
||||
if (d != null) { _name.text = d['name'] ?? ''; _title.text = d['title'] ?? ''; _dept.text = d['department'] ?? ''; _hosp.text = d['hospital'] ?? ''; }
|
||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_Field('姓名', _name),
|
||||
const SizedBox(height: 12),
|
||||
_Field('职称', _title),
|
||||
const SizedBox(height: 12),
|
||||
_Field('科室', _dept),
|
||||
const SizedBox(height: 12),
|
||||
_Field('医院', _hosp),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
||||
child: _saving ? const CircularProgressIndicator(color: Colors.white) : const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
]);
|
||||
if (d != null) {
|
||||
_name.text = d['name'] ?? '';
|
||||
_title.text = d['title'] ?? '';
|
||||
_dept.text = d['department'] ?? '';
|
||||
_hosp.text = d['hospital'] ?? '';
|
||||
}
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Field('姓名', _name),
|
||||
const SizedBox(height: 12),
|
||||
_Field('职称', _title),
|
||||
const SizedBox(height: 12),
|
||||
_Field('科室', _dept),
|
||||
const SizedBox(height: 12),
|
||||
_Field('医院', _hosp),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _saving ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: _saving
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('保存', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -57,13 +99,33 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await ref.read(apiClientProvider).put('/api/doctor/profile', data: {
|
||||
'name': _name.text, 'title': _title.text, 'department': _dept.text, 'hospital': _hosp.text,
|
||||
});
|
||||
await ref
|
||||
.read(apiClientProvider)
|
||||
.put(
|
||||
'/api/doctor/profile',
|
||||
data: {
|
||||
'name': _name.text,
|
||||
'title': _title.text,
|
||||
'department': _dept.text,
|
||||
'hospital': _hosp.text,
|
||||
},
|
||||
);
|
||||
ref.invalidate(_docProfileProvider);
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('保存成功'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppColors.error));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('保存失败'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
@@ -71,11 +133,26 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label; final TextEditingController ctrl;
|
||||
final String label;
|
||||
final TextEditingController ctrl;
|
||||
const _Field(this.label, this.ctrl);
|
||||
@override Widget build(BuildContext context) => Container(
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: TextField(controller: ctrl, decoration: InputDecoration(labelText: label, border: InputBorder.none)),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: TextField(
|
||||
controller: ctrl,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,20 +5,24 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final _reportDetailProvider = FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/reports/$id');
|
||||
return res.data['data'] as Map<String, dynamic>?;
|
||||
});
|
||||
final _reportDetailProvider =
|
||||
FutureProvider.family<Map<String, dynamic>?, String>((ref, id) async {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctor/reports/$id');
|
||||
return res.data['data'] as Map<String, dynamic>?;
|
||||
});
|
||||
|
||||
class DoctorReportDetailPage extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
const DoctorReportDetailPage({super.key, required this.id});
|
||||
|
||||
@override ConsumerState<DoctorReportDetailPage> createState() => _DoctorReportDetailPageState();
|
||||
@override
|
||||
ConsumerState<DoctorReportDetailPage> createState() =>
|
||||
_DoctorReportDetailPageState();
|
||||
}
|
||||
|
||||
class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage> {
|
||||
class _DoctorReportDetailPageState
|
||||
extends ConsumerState<DoctorReportDetailPage> {
|
||||
String _severity = '';
|
||||
String _comment = '';
|
||||
String _recommendation = '';
|
||||
@@ -26,48 +30,90 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
||||
bool _submitted = false;
|
||||
|
||||
static const _severityOptions = ['Normal', 'Abnormal', 'Severe', 'Critical'];
|
||||
static const _severityLabels = {'Normal': '正常', 'Abnormal': '异常', 'Severe': '严重', 'Critical': '危急'};
|
||||
static const _severityColors = {'Normal': 0xFF10B981, 'Abnormal': 0xFFF59E0B, 'Severe': 0xFFEF4444, 'Critical': 0xFF991B1B};
|
||||
static const _severityLabels = {
|
||||
'Normal': '正常',
|
||||
'Abnormal': '异常',
|
||||
'Severe': '严重',
|
||||
'Critical': '危急',
|
||||
};
|
||||
static const _severityColors = {
|
||||
'Normal': 0xFF10B981,
|
||||
'Abnormal': 0xFFF59E0B,
|
||||
'Severe': 0xFFEF4444,
|
||||
'Critical': 0xFF991B1B,
|
||||
};
|
||||
|
||||
static const _templates = ['药物剂量调整', '复查建议', '生活方式干预', '进一步检查', '专科转诊', '无需特殊处理'];
|
||||
static const _templates = [
|
||||
'药物剂量调整',
|
||||
'复查建议',
|
||||
'生活方式干预',
|
||||
'进一步检查',
|
||||
'专科转诊',
|
||||
'无需特殊处理',
|
||||
];
|
||||
|
||||
Future<void> _submitReview() async {
|
||||
if (_severity.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请选择严重程度')));
|
||||
return;
|
||||
}
|
||||
setState(() => _submitting = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/doctor/reports/${widget.id}/review', data: {
|
||||
'severity': _severity,
|
||||
'comment': _comment,
|
||||
'recommendation': _recommendation,
|
||||
});
|
||||
await api.post(
|
||||
'/api/doctor/reports/${widget.id}/review',
|
||||
data: {
|
||||
'severity': _severity,
|
||||
'comment': _comment,
|
||||
'recommendation': _recommendation,
|
||||
},
|
||||
);
|
||||
setState(() => _submitted = true);
|
||||
ref.invalidate(_reportDetailProvider(widget.id));
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('审核提交成功'), backgroundColor: AppColors.success));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('审核提交成功'),
|
||||
backgroundColor: AppColors.success,
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error));
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) setState(() => _submitting = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detail = ref.watch(_reportDetailProvider(widget.id));
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white, elevation: 0,
|
||||
title: const Text('报告审核', style: TextStyle(color: AppColors.textPrimary)),
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text(
|
||||
'报告审核',
|
||||
style: TextStyle(color: AppColors.textPrimary),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: detail.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (data) => data == null ? const Center(child: Text('报告不存在')) : _buildBody(data),
|
||||
data: (data) => data == null
|
||||
? const Center(child: Text('报告不存在'))
|
||||
: _buildBody(data),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -81,168 +127,427 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
||||
final severity = data['severity'] as String?;
|
||||
final fileUrl = data['fileUrl'] as String?;
|
||||
|
||||
return ListView(padding: const EdgeInsets.all(16), children: [
|
||||
// 患者+分类信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Row(children: [
|
||||
CircleAvatar(radius: 24, backgroundColor: const Color(0xFFF0F0FF), child: Text((data['patientName'] ?? '?')[0], style: const TextStyle(color: AppColors.primary))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(data['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 16)),
|
||||
Text('${data['category'] ?? ''} · ${data['fileType'] ?? ''}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
])),
|
||||
_StatusTag(data['status']?.toString() ?? ''),
|
||||
]),
|
||||
),
|
||||
|
||||
// AI预分析摘要
|
||||
if (aiSummary != null && aiSummary.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 患者+分类信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [Container(width: 4, height: 16, decoration: BoxDecoration(color: const Color(0xFF6366F1), borderRadius: BorderRadius.circular(2))), const SizedBox(width: 8), const Text('AI 预分析', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600))]),
|
||||
const SizedBox(height: 8),
|
||||
Text(aiSummary, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary, height: 1.5)),
|
||||
]),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 24,
|
||||
backgroundColor: AppColors.avatarBg,
|
||||
child: Text(
|
||||
(data['patientName'] ?? '?')[0],
|
||||
style: const TextStyle(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
data['patientName'] ?? '',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${data['category'] ?? ''} · ${data['fileType'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_StatusTag(data['status']?.toString() ?? ''),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// AI指标表格
|
||||
if (aiIndicators.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('指标检测', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
...aiIndicators.map((ind) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(ind['name'] ?? '', style: const TextStyle(fontSize: 14))),
|
||||
Text('${ind['value'] ?? ''}', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
if (ind['unit'] != null) Text(' ${ind['unit']}', style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
const SizedBox(width: 8),
|
||||
if (ind['status'] != null)
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: _indicatorColor(ind['status']!).withOpacity(0.1), borderRadius: BorderRadius.circular(4)), child: Text(ind['status']!, style: TextStyle(fontSize: 11, color: _indicatorColor(ind['status']!)))),
|
||||
]),
|
||||
)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
|
||||
// 查看原始报告
|
||||
if (fileUrl != null && fileUrl.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(width: double.infinity, child: OutlinedButton.icon(
|
||||
onPressed: () {}, // TODO: 打开文件查看器
|
||||
icon: const Icon(Icons.visibility, size: 18),
|
||||
label: const Text('查看原始报告'),
|
||||
style: OutlinedButton.styleFrom(foregroundColor: AppColors.primary, side: const BorderSide(color: Color(0xFFE5E7EB)), padding: const EdgeInsets.symmetric(vertical: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
||||
)),
|
||||
],
|
||||
|
||||
// 医生审核区(未审核时显示)
|
||||
if (!reviewed && !_submitted) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)),
|
||||
const SizedBox(height: 16),
|
||||
const Text('医生审核', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 严重程度
|
||||
const Text('严重程度', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: _severityOptions.map((s) => GestureDetector(
|
||||
onTap: () => setState(() => _severity = s),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
// AI预分析摘要
|
||||
if (aiSummary != null && aiSummary.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: _severity == s ? Color(_severityColors[s]!) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: _severity == s ? Color(_severityColors[s]!) : const Color(0xFFE5E7EB)),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Text(_severityLabels[s]!, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: _severity == s ? Colors.white : AppColors.textSecondary)),
|
||||
),
|
||||
)).toList()),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 建议模板
|
||||
const Text('建议模板(可多选)', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: _templates.map((t) {
|
||||
final selected = _recommendation.contains(t);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_recommendation = _recommendation.replaceAll('$t、', '').replaceAll('、$t', '').replaceAll(t, '');
|
||||
} else {
|
||||
_recommendation = _recommendation.isEmpty ? t : '$_recommendation、$t';
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(color: selected ? const Color(0xFFEFF6FF) : Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: selected ? const Color(0xFF0891B2) : const Color(0xFFE5E7EB))),
|
||||
child: Text(t, style: TextStyle(fontSize: 13, color: selected ? const Color(0xFF0891B2) : AppColors.textSecondary)),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'AI 预分析',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
aiSummary,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 自定义评语
|
||||
const Text('评语', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: TextField(
|
||||
maxLines: 5,
|
||||
decoration: const InputDecoration(hintText: '输入审核评语(可选)', hintStyle: TextStyle(fontSize: 14, color: AppColors.textHint), border: InputBorder.none, contentPadding: EdgeInsets.all(16)),
|
||||
onChanged: (v) => _comment = v,
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: _submitting ? null : _submitReview,
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF0891B2), foregroundColor: Colors.white, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14))),
|
||||
child: _submitting ? const CircularProgressIndicator(color: Colors.white) : const Text('提交审核', style: TextStyle(fontSize: 16)),
|
||||
)),
|
||||
],
|
||||
// AI指标表格
|
||||
if (aiIndicators.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'指标检测',
|
||||
style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
...aiIndicators.map(
|
||||
(ind) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
ind['name'] ?? '',
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${ind['value'] ?? ''}',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
if (ind['unit'] != null)
|
||||
Text(
|
||||
' ${ind['unit']}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (ind['status'] != null)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 2,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _indicatorColor(
|
||||
ind['status']!,
|
||||
).withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
ind['status']!,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: _indicatorColor(ind['status']!),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// 已审核结果
|
||||
if (reviewed || _submitted) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(width: double.infinity, height: 1, color: const Color(0xFFE5E7EB)),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFD1FAE5))),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 20), const SizedBox(width: 8), const Text('审核已完成', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF10B981)))]),
|
||||
const SizedBox(height: 12),
|
||||
if (severity != null) _InfoRow('严重程度', _severityLabels[severity] ?? severity),
|
||||
if (doctorRec != null && doctorRec.isNotEmpty) _InfoRow('建议', doctorRec),
|
||||
if (doctorComment != null && doctorComment.isNotEmpty) _InfoRow('评语', doctorComment),
|
||||
]),
|
||||
),
|
||||
// 查看原始报告
|
||||
if (fileUrl != null && fileUrl.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {}, // TODO: 打开文件查看器
|
||||
icon: const Icon(Icons.visibility, size: 18),
|
||||
label: const Text('查看原始报告'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// 医生审核区(未审核时显示)
|
||||
if (!reviewed && !_submitted) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: AppColors.divider,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'医生审核',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// 严重程度
|
||||
const Text(
|
||||
'严重程度',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _severityOptions
|
||||
.map(
|
||||
(s) => GestureDetector(
|
||||
onTap: () => setState(() => _severity = s),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: _severity == s
|
||||
? Color(_severityColors[s]!)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _severity == s
|
||||
? Color(_severityColors[s]!)
|
||||
: AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
_severityLabels[s]!,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: _severity == s
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 建议模板
|
||||
const Text(
|
||||
'建议模板(可多选)',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _templates.map((t) {
|
||||
final selected = _recommendation.contains(t);
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
setState(() {
|
||||
if (selected) {
|
||||
_recommendation = _recommendation
|
||||
.replaceAll('$t、', '')
|
||||
.replaceAll('、$t', '')
|
||||
.replaceAll(t, '');
|
||||
} else {
|
||||
_recommendation = _recommendation.isEmpty
|
||||
? t
|
||||
: '$_recommendation、$t';
|
||||
}
|
||||
});
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? AppColors.primarySoft : Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
t,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: selected
|
||||
? AppColors.primary
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// 自定义评语
|
||||
const Text(
|
||||
'评语',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: TextField(
|
||||
maxLines: 5,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入审核评语(可选)',
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
),
|
||||
onChanged: (v) => _comment = v,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _submitting ? null : _submitReview,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
),
|
||||
child: _submitting
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: const Text('提交审核', style: TextStyle(fontSize: 16)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
// 已审核结果
|
||||
if (reviewed || _submitted) ...[
|
||||
const SizedBox(height: 20),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
height: 1,
|
||||
color: AppColors.divider,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFD1FAE5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle,
|
||||
color: Color(0xFF10B981),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
const Text(
|
||||
'审核已完成',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Color(0xFF10B981),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (severity != null)
|
||||
_InfoRow('严重程度', _severityLabels[severity] ?? severity),
|
||||
if (doctorRec != null && doctorRec.isNotEmpty)
|
||||
_InfoRow('建议', doctorRec),
|
||||
if (doctorComment != null && doctorComment.isNotEmpty)
|
||||
_InfoRow('评语', doctorComment),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
]);
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, String>> _parseIndicators(String? raw) {
|
||||
if (raw == null || raw.isEmpty) return [];
|
||||
try {
|
||||
final list = jsonDecode(raw);
|
||||
if (list is List) return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||
if (list is List)
|
||||
return list.map((e) => Map<String, String>.from(e as Map)).toList();
|
||||
} catch (_) {}
|
||||
return [];
|
||||
}
|
||||
@@ -256,18 +561,39 @@ class _DoctorReportDetailPageState extends ConsumerState<DoctorReportDetailPage>
|
||||
|
||||
Widget _InfoRow(String label, String value) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 56, child: Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint))),
|
||||
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
|
||||
]),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 56,
|
||||
child: Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
Expanded(child: Text(value, style: const TextStyle(fontSize: 14))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _StatusTag extends StatelessWidget {
|
||||
final String s;
|
||||
const _StatusTag(this.s);
|
||||
@override Widget build(BuildContext context) {
|
||||
final (label, color) = s == 'DoctorReviewed' ? ('已审核', const Color(0xFF10B981)) : s == 'PendingDoctor' ? ('待审核', const Color(0xFFF59E0B)) : (s, AppColors.textHint);
|
||||
return Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: color.withOpacity(0.1), borderRadius: BorderRadius.circular(8)), child: Text(label, style: TextStyle(fontSize: 12, color: color)));
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (label, color) = s == 'DoctorReviewed'
|
||||
? ('已审核', const Color(0xFF10B981))
|
||||
: s == 'PendingDoctor'
|
||||
? ('待审核', const Color(0xFFF59E0B))
|
||||
: (s, AppColors.textHint);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(label, style: TextStyle(fontSize: 12, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,62 +4,127 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.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.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>>() ?? [];
|
||||
});
|
||||
|
||||
class DoctorReportsPage extends ConsumerStatefulWidget {
|
||||
const DoctorReportsPage({super.key});
|
||||
@override ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
|
||||
@override
|
||||
ConsumerState<DoctorReportsPage> createState() => _DoctorReportsPageState();
|
||||
}
|
||||
|
||||
class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
String _filter = '';
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final reports = ref.watch(_reportsProvider(_filter));
|
||||
return Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
_FilterChip('全部', '', _filter, () => setState(() => _filter = '')),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip('待审核', 'PendingDoctor', _filter, () => setState(() => _filter = 'PendingDoctor')),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip('已审核', 'DoctorReviewed', _filter, () => setState(() => _filter = 'DoctorReviewed')),
|
||||
]),
|
||||
),
|
||||
Expanded(child: reports.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(child: Text('暂无报告', style: TextStyle(color: AppColors.textHint)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = items[i];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: const Icon(Icons.description, color: AppColors.primary),
|
||||
title: Text(r['patientName'] ?? '', style: const TextStyle(fontWeight: FontWeight.w600)),
|
||||
subtitle: Text('${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'.replaceAll('T', ' ').substring(0, 16)),
|
||||
trailing: const Icon(Icons.chevron_right, color: AppColors.textHint),
|
||||
onTap: () => pushRoute(ref, 'doctorReportDetail', params: {'id': r['id']?.toString() ?? ''}),
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
_FilterChip(
|
||||
'全部',
|
||||
'',
|
||||
_filter,
|
||||
() => setState(() => _filter = ''),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
'待审核',
|
||||
'PendingDoctor',
|
||||
_filter,
|
||||
() => setState(() => _filter = 'PendingDoctor'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_FilterChip(
|
||||
'已审核',
|
||||
'DoctorReviewed',
|
||||
_filter,
|
||||
() => setState(() => _filter = 'DoctorReviewed'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: reports.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, __) => const Center(child: Text('加载失败')),
|
||||
data: (items) => items.isEmpty
|
||||
? const Center(
|
||||
child: Text(
|
||||
'暂无报告',
|
||||
style: TextStyle(color: AppColors.textHint),
|
||||
),
|
||||
)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
itemCount: items.length,
|
||||
itemBuilder: (_, i) {
|
||||
final r = items[i];
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
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,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
r['patientName'] ?? '',
|
||||
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${r['category'] ?? ''} · ${r['createdAt'] ?? ''}'
|
||||
.replaceAll('T', ' ')
|
||||
.substring(0, 16),
|
||||
),
|
||||
trailing: const Icon(
|
||||
Icons.chevron_right,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'doctorReportDetail',
|
||||
params: {'id': r['id']?.toString() ?? ''},
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
]);
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,14 +132,30 @@ class _FilterChip extends StatelessWidget {
|
||||
final String label, value, current;
|
||||
final VoidCallback onTap;
|
||||
const _FilterChip(this.label, this.value, this.current, this.onTap);
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final active = value == current;
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(color: active ? AppColors.primary : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: active ? AppColors.primary : const Color(0xFFE5E7EB))),
|
||||
child: Text(label, style: TextStyle(fontSize: 13, color: active ? Colors.white : AppColors.textSecondary)),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(
|
||||
color: active ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
boxShadow: active
|
||||
? AppColors.buttonShadow
|
||||
: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: active ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -6,53 +6,129 @@ import '../../providers/auth_provider.dart';
|
||||
|
||||
class DoctorSettingsPage extends ConsumerWidget {
|
||||
const DoctorSettingsPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F5F5),
|
||||
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)), leading: IconButton(icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () => popRoute(ref))),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_SettingTile(Icons.notifications_outlined, '推送通知', trailing: Switch(value: true, onChanged: (_) {})),
|
||||
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}),
|
||||
_SettingTile(Icons.article_outlined, '用户协议', onTap: () {}),
|
||||
const SizedBox(height: 24),
|
||||
_SettingTile(Icons.delete_forever_outlined, '删除账号', color: Colors.red, onTap: () => _confirmDelete(context, ref)),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(
|
||||
onPressed: () async { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); },
|
||||
style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.error, elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14), side: const BorderSide(color: Color(0xFFFECACA))), padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('退出登录'),
|
||||
)),
|
||||
]),
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
title: const Text('设置', style: TextStyle(color: AppColors.textPrimary)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_SettingTile(
|
||||
Icons.notifications_outlined,
|
||||
'推送通知',
|
||||
trailing: Switch(value: true, onChanged: (_) {}),
|
||||
),
|
||||
_SettingTile(Icons.description_outlined, '隐私政策', onTap: () {}),
|
||||
_SettingTile(Icons.article_outlined, '用户协议', onTap: () {}),
|
||||
const SizedBox(height: 24),
|
||||
_SettingTile(
|
||||
Icons.delete_forever_outlined,
|
||||
'删除账号',
|
||||
color: Colors.red,
|
||||
onTap: () => _confirmDelete(context, ref),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
},
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.error,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: const BorderSide(color: Color(0xFFFECACA)),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: const Text('退出登录'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref) {
|
||||
showDialog(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除账号'),
|
||||
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
try {
|
||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) goRoute(ref, 'login');
|
||||
} catch (_) {}
|
||||
},
|
||||
child: const Text('确认删除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
));
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除账号'),
|
||||
content: const Text('此操作将永久删除你的所有数据,包括个人信息、问诊记录等。此操作不可撤销。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () async {
|
||||
Navigator.pop(ctx);
|
||||
try {
|
||||
await ref.read(apiClientProvider).delete('/api/user/account');
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
if (context.mounted) goRoute(ref, 'login');
|
||||
} catch (_) {}
|
||||
},
|
||||
child: const Text('确认删除', style: TextStyle(color: Colors.red)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingTile extends StatelessWidget {
|
||||
final IconData icon; final String label; final Color? color; final Widget? trailing; final VoidCallback? onTap;
|
||||
const _SettingTile(this.icon, this.label, {this.color, this.trailing, this.onTap});
|
||||
@override Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 8), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)),
|
||||
child: ListTile(leading: Icon(icon, color: color ?? AppColors.textPrimary), title: Text(label, style: TextStyle(fontSize: 15, color: color ?? AppColors.textPrimary)), trailing: trailing ?? (onTap != null ? const Icon(Icons.chevron_right, color: AppColors.textHint) : null), onTap: onTap),
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color? color;
|
||||
final Widget? trailing;
|
||||
final VoidCallback? onTap;
|
||||
const _SettingTile(
|
||||
this.icon,
|
||||
this.label, {
|
||||
this.color,
|
||||
this.trailing,
|
||||
this.onTap,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: color ?? AppColors.primary),
|
||||
title: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: color ?? AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
trailing:
|
||||
trailing ??
|
||||
(onTap != null
|
||||
? const Icon(Icons.chevron_right, color: AppColors.textHint)
|
||||
: null),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@ import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
@@ -16,19 +15,24 @@ import 'widgets/chat_messages_view.dart';
|
||||
|
||||
class HomePage extends ConsumerStatefulWidget {
|
||||
const HomePage({super.key});
|
||||
@override ConsumerState<HomePage> createState() => _HomePageState();
|
||||
@override
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
String? _pickedImagePath;
|
||||
final Set<ActiveAgent> _welcomedAgents = {};
|
||||
final _focusNode = FocusNode();
|
||||
String? _pickedImagePath;
|
||||
int _lastMsgCount = 0;
|
||||
|
||||
@override void initState() { super.initState(); }
|
||||
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); }
|
||||
@override
|
||||
void dispose() {
|
||||
_textCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
@@ -44,18 +48,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final chatState = ref.watch(chatProvider);
|
||||
final auth = ref.watch(authProvider);
|
||||
final user = auth.user;
|
||||
|
||||
ref.listen(cameraActionProvider, (prev, next) {
|
||||
if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); }
|
||||
else if (next == 'gallery') { _pickImage(ImageSource.gallery); ref.read(cameraActionProvider.notifier).clear(); }
|
||||
if (next == 'camera') {
|
||||
_pickImage(ImageSource.camera);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
} else if (next == 'gallery') {
|
||||
_pickImage(ImageSource.gallery);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
ref.listen(dietActionProvider, (prev, next) {
|
||||
if (next == 'pickFoodCamera') { _pickFoodImage(ImageSource.camera); ref.read(dietActionProvider.notifier).clear(); }
|
||||
else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); }
|
||||
if (next == 'pickFoodCamera') {
|
||||
_pickFoodImage(ImageSource.camera);
|
||||
ref.read(dietActionProvider.notifier).clear();
|
||||
} else if (next == 'pickFoodGallery') {
|
||||
_pickFoodImage(ImageSource.gallery);
|
||||
ref.read(dietActionProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
|
||||
final currentCount = chatState.messages.length;
|
||||
@@ -73,39 +88,74 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SafeArea(
|
||||
child: Column(children: [
|
||||
_buildHeader(user),
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
_buildBottomBar(context),
|
||||
]),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: ChatMessagesView(
|
||||
scrollCtrl: _scrollCtrl,
|
||||
messages: chatState.messages,
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════
|
||||
Widget _buildHeader(dynamic user) {
|
||||
final name = (user?.name != null && user!.name!.isNotEmpty)
|
||||
? user.name
|
||||
: '用户';
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
child: Row(children: [
|
||||
Builder(builder: (ctx) => GestureDetector(
|
||||
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(8),
|
||||
child: Icon(LucideIcons.menu, size: 22, color: AppColors.primary),
|
||||
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.72),
|
||||
border: const Border(bottom: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (ctx) {
|
||||
return _HeaderIconButton(
|
||||
icon: LucideIcons.menu,
|
||||
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||
);
|
||||
},
|
||||
),
|
||||
)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
Text('${_getGreeting()},${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}!',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
])),
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary),
|
||||
),
|
||||
]),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'AI 健康管家',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${_getGreeting()},$name',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_HeaderIconButton(
|
||||
icon: LucideIcons.bell,
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -117,40 +167,51 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return '晚上好';
|
||||
}
|
||||
|
||||
// ═══════ 智能体胶囊 ═══════
|
||||
static final _agentDefs = [
|
||||
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
|
||||
(ActiveAgent.health, '记数据', LucideIcons.heart),
|
||||
(ActiveAgent.health, '记数据', LucideIcons.heartPulse),
|
||||
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
|
||||
(ActiveAgent.medication, '药管家', LucideIcons.pill),
|
||||
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
|
||||
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
|
||||
(ActiveAgent.exercise, '运动', LucideIcons.activity),
|
||||
];
|
||||
|
||||
Widget _buildAgentBar() {
|
||||
return SizedBox(
|
||||
height: 32,
|
||||
height: 42,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
itemCount: _agentDefs.length,
|
||||
separatorBuilder: (_, i) => const SizedBox(width: 8),
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final (agent, label, icon) = _agentDefs[i];
|
||||
return GestureDetector(
|
||||
onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label),
|
||||
onTap: () =>
|
||||
ref.read(chatProvider.notifier).triggerAgent(agent, label),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 17, color: AppColors.textPrimary),
|
||||
const SizedBox(width: 5),
|
||||
Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
]),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: AppColors.primary),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@@ -158,91 +219,129 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════ 底部输入区 ═══════
|
||||
Widget _buildBottomBar(BuildContext context) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()),
|
||||
if (_pickedImagePath != null) _buildImagePreview(),
|
||||
_buildInputBar(),
|
||||
]);
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.76),
|
||||
border: const Border(top: BorderSide(color: AppColors.divider)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_buildAgentBar(),
|
||||
if (_pickedImagePath != null) _buildImagePreview(),
|
||||
_buildInputBar(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
color: AppColors.cardInner,
|
||||
child: Row(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _pickedImagePath = null),
|
||||
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
|
||||
),
|
||||
]),
|
||||
margin: const EdgeInsets.fromLTRB(14, 8, 14, 0),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Image.file(
|
||||
File(_pickedImagePath!),
|
||||
width: 48,
|
||||
height: 48,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'点击发送图片',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
|
||||
),
|
||||
),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _pickedImagePath = null),
|
||||
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputBar() {
|
||||
return Container(
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
GestureDetector(
|
||||
onTap: () => _showAttachmentPicker(context),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: Icon(LucideIcons.plus, size: 24, color: AppColors.textSecondary),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
_RoundToolButton(
|
||||
icon: LucideIcons.plus,
|
||||
onTap: () => _showAttachmentPicker(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxHeight: 120),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: const InputDecoration(
|
||||
hintText: '输入你想说的...',
|
||||
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
filled: true, fillColor: Colors.white,
|
||||
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 118),
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
focusNode: _focusNode,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
maxLines: null,
|
||||
textInputAction: TextInputAction.newline,
|
||||
decoration: InputDecoration(
|
||||
hintText: '输入你想说的...',
|
||||
filled: true,
|
||||
fillColor: Colors.white,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 11,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 40, height: 40,
|
||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [AppColors.blueMeasure, AppColors.primary],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
const SizedBox(width: 8),
|
||||
GestureDetector(
|
||||
onTap: _sendMessage,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
child: const Icon(
|
||||
LucideIcons.send,
|
||||
size: 19,
|
||||
color: Colors.white,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: const Icon(LucideIcons.send, size: 20, color: Colors.white),
|
||||
),
|
||||
),
|
||||
]),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -251,42 +350,62 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
|
||||
title: const Text('拍照'),
|
||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
|
||||
title: const Text('从相册选'),
|
||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open_outlined, color: AppColors.primary),
|
||||
title: const Text('传文件'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('拍照'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.file_open_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('传文件'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picked = await ImagePicker().pickImage(source: source, imageQuality: 85);
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
@@ -295,7 +414,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
|
||||
Future<void> _pickFoodImage(ImageSource source) async {
|
||||
final picked = await ImagePicker().pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 80,
|
||||
maxWidth: 1024,
|
||||
maxHeight: 1024,
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
ref.read(dietProvider.notifier).reset();
|
||||
ref.read(dietProvider.notifier).setImage(picked.path);
|
||||
@@ -304,3 +428,50 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _HeaderIconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _HeaderIconButton({required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 20, color: AppColors.textPrimary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RoundToolButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _RoundToolButton({required this.icon, required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
margin: const EdgeInsets.only(bottom: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(21),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 21, color: AppColors.textSecondary),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,129 +8,391 @@ import '../../providers/data_providers.dart';
|
||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||
final String? id;
|
||||
const MedicationEditPage({super.key, this.id});
|
||||
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
@override
|
||||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
}
|
||||
|
||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _dosageCtrl = TextEditingController();
|
||||
final _notesCtrl = TextEditingController();
|
||||
int _timesPerDay = 2;
|
||||
List<TimeOfDay> _times = [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
|
||||
List<TimeOfDay> _times = [
|
||||
const TimeOfDay(hour: 8, minute: 0),
|
||||
const TimeOfDay(hour: 18, minute: 0),
|
||||
];
|
||||
DateTime _start = DateTime.now();
|
||||
DateTime? _end;
|
||||
bool _loading = false;
|
||||
|
||||
@override void initState() { super.initState(); if (widget.id != null) _load(); }
|
||||
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _notesCtrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.id != null) _load();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameCtrl.dispose();
|
||||
_dosageCtrl.dispose();
|
||||
_notesCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final srv = ref.read(medicationServiceProvider);
|
||||
final list = await srv.getList();
|
||||
final m = list.cast<Map<String, dynamic>?>().firstWhere((x) => x?['id']?.toString() == widget.id, orElse: () => null);
|
||||
final m = list.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(x) => x?['id']?.toString() == widget.id,
|
||||
orElse: () => null,
|
||||
);
|
||||
if (m == null || !mounted) return;
|
||||
_nameCtrl.text = m['name']?.toString() ?? '';
|
||||
_dosageCtrl.text = m['dosage']?.toString() ?? '';
|
||||
_notesCtrl.text = m['notes']?.toString() ?? '';
|
||||
final tod = (m['timeOfDay'] as List?)?.map((t) {
|
||||
final parts = t.toString().split(':');
|
||||
return TimeOfDay(hour: int.tryParse(parts[0]) ?? 8, minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0);
|
||||
}).toList() ?? [const TimeOfDay(hour: 8, minute: 0), const TimeOfDay(hour: 18, minute: 0)];
|
||||
final tod =
|
||||
(m['timeOfDay'] as List?)?.map((t) {
|
||||
final parts = t.toString().split(':');
|
||||
return TimeOfDay(
|
||||
hour: int.tryParse(parts[0]) ?? 8,
|
||||
minute: int.tryParse(parts.length > 1 ? parts[1] : '0') ?? 0,
|
||||
);
|
||||
}).toList() ??
|
||||
[
|
||||
const TimeOfDay(hour: 8, minute: 0),
|
||||
const TimeOfDay(hour: 18, minute: 0),
|
||||
];
|
||||
_timesPerDay = tod.length;
|
||||
_times = tod;
|
||||
if (m['startDate'] != null) _start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
|
||||
if (m['startDate'] != null)
|
||||
_start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
|
||||
if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString());
|
||||
setState(() {});
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
final name = _nameCtrl.text.trim();
|
||||
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入药品名称'))); return; }
|
||||
if (name.isEmpty) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(const SnackBar(content: Text('请输入药品名称')));
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
final data = {
|
||||
'name': name, 'dosage': _dosageCtrl.text.trim(), 'frequency': 'Daily',
|
||||
'name': name,
|
||||
'dosage': _dosageCtrl.text.trim(),
|
||||
'frequency': 'Daily',
|
||||
'notes': _notesCtrl.text.trim(),
|
||||
'timeOfDay': _times.map((t) => '${t.hour.toString().padLeft(2,'0')}:${t.minute.toString().padLeft(2,'0')}').toList(),
|
||||
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
|
||||
if (_end != null) 'endDate': '${_end!.year}-${_end!.month.toString().padLeft(2,'0')}-${_end!.day.toString().padLeft(2,'0')}',
|
||||
'timeOfDay': _times
|
||||
.map(
|
||||
(t) =>
|
||||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}',
|
||||
)
|
||||
.toList(),
|
||||
'startDate':
|
||||
'${_start.year}-${_start.month.toString().padLeft(2, '0')}-${_start.day.toString().padLeft(2, '0')}',
|
||||
if (_end != null)
|
||||
'endDate':
|
||||
'${_end!.year}-${_end!.month.toString().padLeft(2, '0')}-${_end!.day.toString().padLeft(2, '0')}',
|
||||
'source': 'Manual',
|
||||
};
|
||||
final srv = ref.read(medicationServiceProvider);
|
||||
try {
|
||||
if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); }
|
||||
ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider);
|
||||
if (mounted) { popRoute(ref); }
|
||||
} catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.error)); }
|
||||
if (widget.id != null) {
|
||||
await srv.update(widget.id!, data);
|
||||
} else {
|
||||
await srv.create(data);
|
||||
}
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
if (mounted) {
|
||||
popRoute(ref);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted)
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('保存失败'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
void _updateTimes(int n) {
|
||||
setState(() {
|
||||
_timesPerDay = n;
|
||||
while (_times.length < n) _times.add(const TimeOfDay(hour: 12, minute: 0));
|
||||
while (_times.length < n)
|
||||
_times.add(const TimeOfDay(hour: 12, minute: 0));
|
||||
while (_times.length > n) _times.removeLast();
|
||||
});
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
// 名称+剂量 一行
|
||||
Row(children: [
|
||||
Expanded(flex: 3, child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林')),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(flex: 2, child: _field('剂量', _dosageCtrl, hint: '如: 100mg')),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
// 频率选择
|
||||
_label('每日服药次数'), const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, children: List.generate(4, (i) => _chip('${i + 1}次', _timesPerDay == i + 1, () => _updateTimes(i + 1)))),
|
||||
const SizedBox(height: 16),
|
||||
// 时间选择
|
||||
_label('服药时间'), const SizedBox(height: 8),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: List.generate(_timesPerDay, (i) => GestureDetector(
|
||||
onTap: () async {
|
||||
final t = await showTimePicker(context: context, initialTime: _times[i]);
|
||||
if (t != null) setState(() => _times[i] = t);
|
||||
},
|
||||
child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border, width: 1)),
|
||||
child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w600))),
|
||||
))),
|
||||
const SizedBox(height: 16),
|
||||
// 开始+结束 一行
|
||||
Row(children: [
|
||||
Expanded(child: _dateField('开始日期', _start, (d) => setState(() => _start = d))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _dateFieldOpt('结束日期(可选)', _end, (d) => setState(() => _end = d))),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, side: const BorderSide(color: AppColors.primary, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)))),
|
||||
const SizedBox(height: 20),
|
||||
]),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// 名称+剂量 一行
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _field('药品名称', _nameCtrl, hint: '如: 阿司匹林'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _field('剂量', _dosageCtrl, hint: '如: 100mg'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 频率选择
|
||||
_label('每日服药次数'), const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: List.generate(
|
||||
4,
|
||||
(i) => _chip(
|
||||
'${i + 1}次',
|
||||
_timesPerDay == i + 1,
|
||||
() => _updateTimes(i + 1),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 时间选择
|
||||
_label('服药时间'), const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: List.generate(
|
||||
_timesPerDay,
|
||||
(i) => GestureDetector(
|
||||
onTap: () async {
|
||||
final t = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: _times[i],
|
||||
);
|
||||
if (t != null) setState(() => _times[i] = t);
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 10,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.border, width: 1),
|
||||
),
|
||||
child: Text(
|
||||
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 开始+结束 一行
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _dateField(
|
||||
'开始日期',
|
||||
_start,
|
||||
(d) => setState(() => _start = d),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: _dateFieldOpt(
|
||||
'结束日期(可选)',
|
||||
_end,
|
||||
(d) => setState(() => _end = d),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: _loading ? null : _save,
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: AppColors.primary,
|
||||
side: const BorderSide(color: AppColors.primary, width: 1.5),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
child: Text(
|
||||
_loading ? '保存中...' : '保存',
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _label(String text) => Text(text, style: TextStyle(fontSize: 17, color: AppColors.textSecondary));
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_label(label), const SizedBox(height: 6),
|
||||
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppTheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)),
|
||||
]);
|
||||
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? Colors.white : AppColors.cardInner, borderRadius: BorderRadius.circular(16), border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? AppColors.primary : AppColors.textSecondary, fontWeight: FontWeight.w500))));
|
||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_label(label), const SizedBox(height: 6),
|
||||
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
||||
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))),
|
||||
]);
|
||||
Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_label(label), const SizedBox(height: 6),
|
||||
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
|
||||
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
|
||||
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : AppTheme.textHint)),
|
||||
if (val != null) const Spacer(),
|
||||
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)),
|
||||
]))),
|
||||
]);
|
||||
Widget _label(String text) => Text(
|
||||
text,
|
||||
style: TextStyle(fontSize: 17, color: AppColors.textSecondary),
|
||||
);
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) =>
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_label(label),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: ctrl,
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
filled: true,
|
||||
fillColor: AppTheme.surface,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
borderSide: const BorderSide(color: AppColors.primary),
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 12,
|
||||
),
|
||||
),
|
||||
style: const TextStyle(fontSize: 19),
|
||||
),
|
||||
],
|
||||
);
|
||||
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? Colors.white : AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: sel ? AppColors.primary : AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) =>
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_label(label),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: val,
|
||||
firstDate: DateTime(2024),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (d != null) cb(d);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${val.month}/${val.day}',
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
Widget _dateFieldOpt(
|
||||
String label,
|
||||
DateTime? val,
|
||||
ValueChanged<DateTime?> cb,
|
||||
) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_label(label),
|
||||
const SizedBox(height: 6),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final d = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: val ?? DateTime.now(),
|
||||
firstDate: DateTime(2024),
|
||||
lastDate: DateTime(2030),
|
||||
);
|
||||
if (d != null) cb(d);
|
||||
},
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
val != null ? '${val.month}/${val.day}' : '不设置',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: val != null ? null : AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
if (val != null) const Spacer(),
|
||||
if (val != null)
|
||||
GestureDetector(
|
||||
onTap: () => cb(null),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 19,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,27 +9,41 @@ import '../../widgets/common_widgets.dart';
|
||||
|
||||
class MedicationListPage extends ConsumerStatefulWidget {
|
||||
const MedicationListPage({super.key});
|
||||
@override ConsumerState<MedicationListPage> createState() => _MedicationListPageState();
|
||||
@override
|
||||
ConsumerState<MedicationListPage> createState() => _MedicationListPageState();
|
||||
}
|
||||
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> with SingleTickerProviderStateMixin {
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||
with SingleTickerProviderStateMixin {
|
||||
String _filter = '';
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
late final _tabCtrl = TabController(length: 3, vsync: this);
|
||||
|
||||
@override void initState() {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_tabCtrl.addListener(() {
|
||||
if (!_tabCtrl.indexIsChanging) {
|
||||
setState(() { _filter = ['', 'active', 'inactive'][_tabCtrl.index]; });
|
||||
setState(() {
|
||||
_filter = ['', 'active', 'inactive'][_tabCtrl.index];
|
||||
});
|
||||
_load();
|
||||
}
|
||||
});
|
||||
_load();
|
||||
}
|
||||
@override void dispose() { _tabCtrl.dispose(); super.dispose(); }
|
||||
|
||||
void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); }
|
||||
@override
|
||||
void dispose() {
|
||||
_tabCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ref.read(medicationServiceProvider).getMedications(_filter);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _delete(String id) async {
|
||||
await ref.read(medicationServiceProvider).deleteMedication(id);
|
||||
@@ -39,10 +53,14 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
||||
|
||||
static const _tabs = ['全部', '服用中', '已停药'];
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text('用药管理'),
|
||||
bottom: PreferredSize(
|
||||
preferredSize: const Size.fromHeight(48),
|
||||
@@ -51,7 +69,10 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.primary,
|
||||
unselectedLabelColor: AppColors.textHint,
|
||||
labelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600),
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
tabs: _tabs.map((t) => Tab(child: Text(t))).toList(),
|
||||
),
|
||||
),
|
||||
@@ -65,72 +86,124 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> with Si
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
final list = snap.data ?? [];
|
||||
if (list.isEmpty) {
|
||||
return const AppEmptyState(
|
||||
icon: Icons.medication_outlined,
|
||||
title: '暂无用药',
|
||||
subtitle: '点击右下角➕添加药品',
|
||||
);
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final m = list[i];
|
||||
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? '';
|
||||
final isActive = m['isActive'] == true;
|
||||
final id = m['id']?.toString() ?? '';
|
||||
return SwipeDeleteTile(
|
||||
key: Key(id),
|
||||
onDelete: () => _delete(id),
|
||||
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||
margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Icon(Icons.medication_outlined, size: 25, color: isActive ? AppColors.primary : AppTheme.textSub),
|
||||
),
|
||||
const SizedBox(width: AppTheme.sMd),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)),
|
||||
if (!isActive) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.bgSoft,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
const SizedBox(height: 2),
|
||||
Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)),
|
||||
])),
|
||||
Icon(Icons.chevron_right, size: 21, color: AppTheme.border),
|
||||
]),
|
||||
),
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
final list = snap.data ?? [];
|
||||
if (list.isEmpty) {
|
||||
return const AppEmptyState(
|
||||
icon: Icons.medication_outlined,
|
||||
title: '暂无用药',
|
||||
subtitle: '点击右下角➕添加药品',
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
}
|
||||
return ListView.builder(
|
||||
padding: const EdgeInsets.fromLTRB(0, 8, 0, 80),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final m = list[i];
|
||||
final times =
|
||||
(m['timeOfDay'] as List?)
|
||||
?.map((t) => t.toString().substring(0, 5))
|
||||
.join(' ') ??
|
||||
'';
|
||||
final isActive = m['isActive'] == true;
|
||||
final id = m['id']?.toString() ?? '';
|
||||
return SwipeDeleteTile(
|
||||
key: Key(id),
|
||||
onDelete: () => _delete(id),
|
||||
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||
margin: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.sLg,
|
||||
vertical: 4,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.medication_outlined,
|
||||
size: 25,
|
||||
color: isActive
|
||||
? AppColors.primary
|
||||
: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.sMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
m['name']?.toString() ?? '',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isActive
|
||||
? AppTheme.text
|
||||
: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
if (!isActive) ...[
|
||||
const SizedBox(width: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 6,
|
||||
vertical: 1,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.bgSoft,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
'已停',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${m['dosage'] ?? ''} $times',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppTheme.border,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import '../../providers/auth_provider.dart';
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final user = auth.user;
|
||||
final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称';
|
||||
@@ -22,103 +23,179 @@ class ProfilePage extends ConsumerWidget {
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(children: [
|
||||
const SizedBox(height: 20),
|
||||
child: Column(
|
||||
children: [
|
||||
const SizedBox(height: 20),
|
||||
|
||||
// 头像区
|
||||
Center(
|
||||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: AppColors.cardInner,
|
||||
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
|
||||
child: user?.avatarUrl == null
|
||||
? const Icon(Icons.person, size: 48, color: AppColors.textHint)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Icon(Icons.phone_android, size: 16, color: AppColors.textSecondary),
|
||||
const SizedBox(width: 6),
|
||||
Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)),
|
||||
]),
|
||||
),
|
||||
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 基础信息卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(children: [
|
||||
_infoRow(Icons.person_outline, '姓名', name),
|
||||
const Divider(height: 24, color: AppColors.borderLight),
|
||||
_infoRow(Icons.phone_android, '手机号', phone.isNotEmpty ? phone : '未绑定'),
|
||||
const Divider(height: 24, color: AppColors.borderLight),
|
||||
_infoRow(Icons.health_and_safety, '健康档案', '查看详情', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||
]),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 退出登录
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _logout(context, ref),
|
||||
icon: const Icon(Icons.logout, size: 18),
|
||||
label: const Text('退出登录', style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
// 头像区
|
||||
Center(
|
||||
child: CircleAvatar(
|
||||
radius: 50,
|
||||
backgroundColor: AppColors.cardInner,
|
||||
backgroundImage: user?.avatarUrl != null
|
||||
? NetworkImage(user!.avatarUrl!)
|
||||
: null,
|
||||
child: user?.avatarUrl == null
|
||||
? const Icon(
|
||||
Icons.person,
|
||||
size: 48,
|
||||
color: AppColors.textHint,
|
||||
)
|
||||
: null,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.phone_android,
|
||||
size: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
phone.isNotEmpty ? phone : '未绑定手机',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
]),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// 基础信息卡片
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
_infoRow(Icons.person_outline, '姓名', name),
|
||||
const Divider(height: 24, color: AppColors.borderLight),
|
||||
_infoRow(
|
||||
Icons.phone_android,
|
||||
'手机号',
|
||||
phone.isNotEmpty ? phone : '未绑定',
|
||||
),
|
||||
const Divider(height: 24, color: AppColors.borderLight),
|
||||
_infoRow(
|
||||
Icons.health_and_safety,
|
||||
'健康档案',
|
||||
'查看详情',
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
|
||||
// 退出登录
|
||||
SizedBox(
|
||||
width: 200,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _logout(context, ref),
|
||||
icon: const Icon(Icons.logout, size: 18),
|
||||
label: const Text('退出登录', style: TextStyle(fontSize: 16)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: const BorderSide(color: AppColors.error),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _infoRow(IconData icon, String label, String value, {VoidCallback? onTap}) {
|
||||
Widget _infoRow(
|
||||
IconData icon,
|
||||
String label,
|
||||
String value, {
|
||||
VoidCallback? onTap,
|
||||
}) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 22, color: AppColors.primary),
|
||||
),
|
||||
child: Icon(icon, size: 22, color: AppColors.textSecondary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
const SizedBox(height: 3),
|
||||
Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
]),
|
||||
),
|
||||
if (onTap != null) const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint),
|
||||
]),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -126,12 +203,20 @@ class ProfilePage extends ConsumerWidget {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rXl),
|
||||
),
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定退出当前账号?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('确定', style: TextStyle(color: AppColors.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../widgets/service_package_card.dart';
|
||||
|
||||
@@ -34,27 +34,54 @@ class ServicePackageDetailPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [package.headerColor, package.headerColor.withAlpha(180)],
|
||||
colors: [
|
||||
package.headerColor,
|
||||
package.headerColor.withAlpha(180),
|
||||
],
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: AppColors.cardShadow,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withAlpha(40),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text('VIP 产品权益', style: TextStyle(fontSize: 15, color: Colors.white, fontWeight: FontWeight.w600)),
|
||||
child: const Text(
|
||||
'VIP 产品权益',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(package.title, style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
Text(
|
||||
package.title,
|
||||
style: const TextStyle(
|
||||
fontSize: 25,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(package.subtitle, style: TextStyle(fontSize: 17, color: Colors.white.withAlpha(200))),
|
||||
Text(
|
||||
package.subtitle,
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
color: Colors.white.withAlpha(200),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -64,30 +91,68 @@ class ServicePackageDetailPage extends ConsumerWidget {
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
children: package.services.map((s) => SizedBox(
|
||||
width: (MediaQuery.of(context).size.width - 72) / 4,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
children: package.services
|
||||
.map(
|
||||
(s) => SizedBox(
|
||||
width: (MediaQuery.of(context).size.width - 72) / 4,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(
|
||||
s.icon,
|
||||
size: 25,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
s.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Icon(s.icon, size: 25, color: AppTheme.primary),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(s.label, style: const TextStyle(fontSize: 15, color: AppTheme.textSub), textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
)).toList(),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
// 适用人群
|
||||
_Section(title: '适用人群', child: Text(package.targetAudience, style: const TextStyle(fontSize: 17, color: AppTheme.textSub, height: 1.6))),
|
||||
_Section(
|
||||
title: '适用人群',
|
||||
child: Text(
|
||||
package.targetAudience,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
color: AppTheme.textSub,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
// 详细说明
|
||||
...package.detailSections.map((s) => _Section(title: s.title, child: Text(s.content, style: const TextStyle(fontSize: 17, color: AppTheme.textSub, height: 1.6)))),
|
||||
...package.detailSections.map(
|
||||
(s) => _Section(
|
||||
title: s.title,
|
||||
child: Text(
|
||||
s.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
color: AppTheme.textSub,
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -110,11 +175,20 @@ class _Section extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.text)),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
child,
|
||||
],
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -32,7 +32,9 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
if (analysis == null) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(title: const Text('报告解读')),
|
||||
body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
|
||||
body: const Center(
|
||||
child: CircularProgressIndicator(color: AppColors.primary),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -41,7 +43,14 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(displayType, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
title: Text(
|
||||
displayType,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () {
|
||||
@@ -52,57 +61,93 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
// ── 1. 指标分析 ──
|
||||
if (analysis.indicators.isNotEmpty) ...[
|
||||
_sectionTitle('指标分析'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: _cardDeco(),
|
||||
child: Column(
|
||||
children: analysis.indicators.map((ind) => _indicatorRow(ind)).toList(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// ── 2. 综合解读 ──
|
||||
_sectionTitle('综合解读'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _cardDeco(),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(analysis.summary.isNotEmpty ? analysis.summary : 'AI 正在分析中,请稍后刷新查看',
|
||||
style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.7)),
|
||||
const SizedBox(height: 10),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── 1. 指标分析 ──
|
||||
if (analysis.indicators.isNotEmpty) ...[
|
||||
_sectionTitle('指标分析'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(10)),
|
||||
child: const Text('以上为AI预解读,具体请以医生诊断为准',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
decoration: _cardDeco(),
|
||||
child: Column(
|
||||
children: analysis.indicators
|
||||
.map((ind) => _indicatorRow(ind))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
// ── 3. 医生审核意见 ──
|
||||
_sectionTitle('医生审核意见'),
|
||||
const SizedBox(height: 8),
|
||||
if (isReviewed)
|
||||
_doctorReviewCard(reportItem!)
|
||||
else
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
// ── 2. 综合解读 ──
|
||||
_sectionTitle('综合解读'),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _cardDeco(),
|
||||
child: const Row(children: [
|
||||
Icon(Icons.hourglass_empty, size: 18, color: AppColors.textHint),
|
||||
SizedBox(width: 8),
|
||||
Text('待审核', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
Spacer(),
|
||||
Text('AI 预解读', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
]),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
analysis.summary.isNotEmpty
|
||||
? analysis.summary
|
||||
: 'AI 正在分析中,请稍后刷新查看',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.7,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Text(
|
||||
'以上为AI预解读,具体请以医生诊断为准',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
// ── 3. 医生审核意见 ──
|
||||
_sectionTitle('医生审核意见'),
|
||||
const SizedBox(height: 8),
|
||||
if (isReviewed)
|
||||
_doctorReviewCard(reportItem!)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _cardDeco(),
|
||||
child: const Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.hourglass_empty,
|
||||
size: 18,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'待审核',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Spacer(),
|
||||
Text(
|
||||
'AI 预解读',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -110,14 +155,31 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
BoxDecoration _cardDeco() => BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
);
|
||||
|
||||
Widget _sectionTitle(String text) => Row(children: [
|
||||
Container(width: 4, height: 16, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(2))),
|
||||
const SizedBox(width: 8),
|
||||
Text(text, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
|
||||
]);
|
||||
Widget _sectionTitle(String text) => Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 4,
|
||||
height: 16,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
Widget _indicatorRow(Indicator ind) {
|
||||
final (color, icon) = switch (ind.status) {
|
||||
@@ -127,19 +189,47 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
};
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.borderLight))),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(ind.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
|
||||
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
|
||||
]),
|
||||
),
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)),
|
||||
]),
|
||||
decoration: const BoxDecoration(
|
||||
border: Border(bottom: BorderSide(color: AppColors.borderLight)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
ind.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (ind.referenceRange != null &&
|
||||
ind.referenceRange!.isNotEmpty)
|
||||
Text(
|
||||
'参考值: ${ind.referenceRange}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(icon, size: 14, color: color),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${ind.value} ${ind.unit}',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -147,27 +237,54 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: _cardDeco(),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
if (report.doctorName != null)
|
||||
Text('审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(report.doctorComment!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (report.doctorName != null)
|
||||
Text(
|
||||
'审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}',
|
||||
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
if (report.doctorComment != null &&
|
||||
report.doctorComment!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
report.doctorComment!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
if (report.doctorRecommendation != null &&
|
||||
report.doctorRecommendation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
report.doctorRecommendation!,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
|
||||
child: Text(report.doctorRecommendation!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,13 +5,14 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
||||
ReportNotifier.new,
|
||||
);
|
||||
|
||||
class ReportState {
|
||||
final List<ReportItem> reports;
|
||||
@@ -48,8 +49,8 @@ class ReportItem {
|
||||
final DateTime uploadedAt;
|
||||
final String? imagePath;
|
||||
final bool hasAnalysis;
|
||||
final String status; // PendingDoctor | DoctorReviewed
|
||||
final String? severity; // Normal | Abnormal | Severe | Critical
|
||||
final String status; // PendingDoctor | DoctorReviewed
|
||||
final String? severity; // Normal | Abnormal | Severe | Critical
|
||||
final String? doctorComment;
|
||||
final String? doctorRecommendation;
|
||||
final String? doctorName;
|
||||
@@ -117,14 +118,17 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
final m = r as Map<String, dynamic>;
|
||||
final rawTitle = m['title']?.toString() ?? '';
|
||||
final rawCat = m['category']?.toString() ?? '';
|
||||
final title = rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other'
|
||||
final title =
|
||||
rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other'
|
||||
? rawTitle
|
||||
: _catTitle(rawCat);
|
||||
return ReportItem(
|
||||
id: m['id']?.toString() ?? '',
|
||||
title: title,
|
||||
type: m['fileType']?.toString() ?? 'Image',
|
||||
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
uploadedAt:
|
||||
DateTime.tryParse(m['createdAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
hasAnalysis: m['aiSummary'] != null,
|
||||
status: m['status']?.toString() ?? 'PendingDoctor',
|
||||
severity: m['severity']?.toString(),
|
||||
@@ -199,12 +203,17 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final file = File(path);
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||
'file': await MultipartFile.fromFile(
|
||||
file.path,
|
||||
filename: file.path.split('/').last,
|
||||
),
|
||||
});
|
||||
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
|
||||
final createRes = await api.dio.post('/api/reports', data: formData);
|
||||
final data = createRes.data;
|
||||
final reportId = data is Map ? (data['data']?['id']?.toString() ?? '') : '';
|
||||
final reportId = data is Map
|
||||
? (data['data']?['id']?.toString() ?? '')
|
||||
: '';
|
||||
|
||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||
loadReports();
|
||||
@@ -245,32 +254,42 @@ class ReportListPage extends ConsumerWidget {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(title: const Text('报告管理')),
|
||||
body: const Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircularProgressIndicator(color: AppColors.primary),
|
||||
SizedBox(height: 16),
|
||||
Text('AI 正在分析报告...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircularProgressIndicator(color: AppColors.primary),
|
||||
SizedBox(height: 16),
|
||||
Text(
|
||||
'AI 正在分析报告...',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text('报告管理'),
|
||||
centerTitle: true,
|
||||
),
|
||||
floatingActionButton: _buildUploadButton(context, ref),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||
child: state.reports.isEmpty
|
||||
? ListView(children: [_buildEmptyState(context)])
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.reports.length,
|
||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
||||
),
|
||||
),
|
||||
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
|
||||
child: state.reports.isEmpty
|
||||
? ListView(children: [_buildEmptyState(context)])
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.reports.length,
|
||||
itemBuilder: (context, index) =>
|
||||
_buildReportCard(context, ref, state.reports[index]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -287,41 +306,68 @@ class ReportListPage extends ConsumerWidget {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Wrap(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
|
||||
title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
||||
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
|
||||
title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
|
||||
if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.picture_as_pdf_outlined, color: AppColors.primary),
|
||||
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']);
|
||||
if (result != null && result.files.isNotEmpty) ref.read(reportProvider.notifier).uploadFile(result.files.first.path!);
|
||||
},
|
||||
),
|
||||
]),
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('拍照上传', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.camera,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked != null)
|
||||
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.photo_library_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('从相册选择', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(
|
||||
source: ImageSource.gallery,
|
||||
imageQuality: 85,
|
||||
);
|
||||
if (picked != null)
|
||||
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
);
|
||||
if (result != null && result.files.isNotEmpty)
|
||||
ref
|
||||
.read(reportProvider.notifier)
|
||||
.uploadFile(result.files.first.path!);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -329,27 +375,55 @@ class ReportListPage extends ConsumerWidget {
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 100, height: 100,
|
||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(50)),
|
||||
child: const Icon(Icons.description_outlined, size: 44, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('暂无检查报告', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('点击右下角按钮上传报告', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
|
||||
]),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 100,
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
size: 44,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'暂无检查报告',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'点击右下角按钮上传报告',
|
||||
style: TextStyle(fontSize: 16, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) {
|
||||
final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title;
|
||||
Widget _buildReportCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ReportItem report,
|
||||
) {
|
||||
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
||||
? '检查报告'
|
||||
: report.title;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Material(
|
||||
@@ -360,47 +434,117 @@ class ReportListPage extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 48, height: 48,
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight)),
|
||||
child: Icon(Icons.description_outlined, size: 26, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(displayTitle, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
const SizedBox(height: 4),
|
||||
Text(_formatDate(report.uploadedAt), style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
]),
|
||||
),
|
||||
if (report.status == 'DoctorReviewed')
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success, fontWeight: FontWeight.w500)),
|
||||
)
|
||||
else if (!report.hasAnalysis)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('分析中', style: TextStyle(fontSize: 13, color: AppColors.textHint, fontWeight: FontWeight.w500)),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)),
|
||||
child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning, fontWeight: FontWeight.w500)),
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
size: 26,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _confirmDelete(context, ref, report.id),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(Icons.delete_outline, size: 20, color: AppColors.textHint),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayTitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(report.uploadedAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
if (report.status == 'DoctorReviewed')
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'已审核',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.success,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
)
|
||||
else if (!report.hasAnalysis)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'分析中',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
)
|
||||
else
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.warningLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Text(
|
||||
'待审核',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.warning,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _confirmDelete(context, ref, report.id),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.all(6),
|
||||
child: Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -414,9 +558,15 @@ class ReportListPage extends ConsumerWidget {
|
||||
title: const Text('删除报告'),
|
||||
content: const Text('确定要删除这份报告吗?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () { Navigator.pop(ctx); ref.read(reportProvider.notifier).deleteReport(id); },
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(reportProvider.notifier).deleteReport(id);
|
||||
},
|
||||
child: const Text('删除', style: TextStyle(color: AppColors.error)),
|
||||
),
|
||||
],
|
||||
@@ -443,4 +593,3 @@ String _catTitle(String c) => switch (c) {
|
||||
'Image' => '影像检查报告',
|
||||
_ => '检查报告',
|
||||
};
|
||||
|
||||
|
||||
@@ -6,9 +6,10 @@ import '../../core/navigation_provider.dart';
|
||||
|
||||
// ── 通知偏好状态 ──
|
||||
|
||||
final notificationPrefsProvider = NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||
NotificationPrefsNotifier.new,
|
||||
);
|
||||
final notificationPrefsProvider =
|
||||
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||
NotificationPrefsNotifier.new,
|
||||
);
|
||||
|
||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||
@override
|
||||
@@ -55,10 +56,20 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new, color: AppColors.textPrimary),
|
||||
icon: const Icon(
|
||||
Icons.arrow_back_ios_new,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
title: Text(
|
||||
'消息通知',
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
@@ -72,7 +83,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '允许推送通知',
|
||||
subtitle: '关闭后将不再收到任何系统推送',
|
||||
value: prefs['pushEnabled'] ?? true,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('pushEnabled'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('pushEnabled'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -85,7 +98,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '用药提醒',
|
||||
subtitle: '服药时间到达时提醒您',
|
||||
value: prefs['medication'] ?? true,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('medication'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('medication'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
@@ -94,7 +109,9 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '健康异常提醒',
|
||||
subtitle: '检测到数据异常时及时通知',
|
||||
value: prefs['healthAlert'] ?? true,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('healthAlert'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthAlert'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.event_available_rounded,
|
||||
@@ -103,16 +120,20 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '复查日期提醒',
|
||||
subtitle: '复查日前一天提醒您预约',
|
||||
value: prefs['followUp'] ?? true,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('followUp'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('followUp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.smart_toy_outlined,
|
||||
icon: Icons.forum_outlined,
|
||||
iconBg: AppColors.iconBg,
|
||||
iconColor: AppTheme.primary,
|
||||
title: 'AI 回复通知',
|
||||
subtitle: 'AI 助手回复时发送通知',
|
||||
value: prefs['aiReply'] ?? false,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('aiReply'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('aiReply'),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
@@ -122,24 +143,69 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '开启免打扰模式',
|
||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||
value: dndOn,
|
||||
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('dndEnabled'),
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('dndEnabled'),
|
||||
),
|
||||
if (dndOn) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
child: Row(children: [
|
||||
Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async {
|
||||
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0));
|
||||
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked);
|
||||
})),
|
||||
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: AppTheme.textHint))),
|
||||
Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async {
|
||||
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0));
|
||||
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked);
|
||||
})),
|
||||
]),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '开始',
|
||||
time: '22:00',
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
||||
);
|
||||
if (picked != null && context.mounted)
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndStart(picked);
|
||||
},
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'~',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '结束',
|
||||
time: '08:00',
|
||||
onTap: () async {
|
||||
final picked = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||||
);
|
||||
if (picked != null && context.mounted)
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndEnd(picked);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
@@ -158,7 +224,17 @@ class _SectionTitle extends StatelessWidget {
|
||||
const _SectionTitle({required this.title});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textSecondary)));
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 4, bottom: 10),
|
||||
child: Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,7 +248,9 @@ class _SwitchTile extends StatelessWidget {
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _SwitchTile({
|
||||
this.icon, this.iconBg, this.iconColor,
|
||||
this.icon,
|
||||
this.iconBg,
|
||||
this.iconColor,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
@@ -184,18 +262,58 @@ class _SwitchTile extends StatelessWidget {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
|
||||
child: Row(children: [
|
||||
if (icon != null) ...[
|
||||
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)),
|
||||
const SizedBox(width: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg ?? AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 23,
|
||||
color: iconColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
],
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSub),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: value,
|
||||
onChanged: onChanged,
|
||||
activeThumbColor: AppTheme.primary,
|
||||
activeTrackColor: AppColors.primaryLight,
|
||||
),
|
||||
],
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(title, style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w500)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: AppTheme.textSub)),
|
||||
])),
|
||||
Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: AppColors.primaryLight),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -204,14 +322,39 @@ class _TimeButton extends StatelessWidget {
|
||||
final String label;
|
||||
final String time;
|
||||
final VoidCallback onTap;
|
||||
const _TimeButton({required this.label, required this.time, required this.onTap});
|
||||
const _TimeButton({
|
||||
required this.label,
|
||||
required this.time,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(onTap: onTap, child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(border: Border.all(color: AppColors.border), borderRadius: BorderRadius.circular(AppTheme.rSm)),
|
||||
child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: AppTheme.textHint)), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]),
|
||||
));
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(color: AppColors.border),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
style: TextStyle(fontSize: 14, color: AppTheme.textHint),
|
||||
),
|
||||
Text(
|
||||
time,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,17 +33,15 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
@override
|
||||
AuthState build() {
|
||||
_checkAuth();
|
||||
return const AuthState(isLoading: true);
|
||||
return const AuthState(isLoggedIn: false, isLoading: false);
|
||||
}
|
||||
|
||||
Future<void> _checkAuth() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
final refresh = await db.read('refresh_token');
|
||||
if (refresh == null) {
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
return;
|
||||
}
|
||||
if (refresh == null) return; // 无token,保持 isLoggedIn: false
|
||||
|
||||
state = const AuthState(isLoading: true);
|
||||
try {
|
||||
final response = await Dio(BaseOptions(baseUrl: baseUrl))
|
||||
.post('/api/auth/refresh', data: {'refreshToken': refresh});
|
||||
@@ -65,6 +63,8 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
Future<void> _loadProfile() async {
|
||||
// Admin 不查 profile(无 User 记录)
|
||||
if (state.user?.role == 'Admin') return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.get('/api/user/profile');
|
||||
@@ -95,12 +95,11 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
}
|
||||
|
||||
/// 注册(新用户,需选身份)
|
||||
Future<String?> register(String phone, String code, String role, {String? inviteCode}) async {
|
||||
Future<String?> register(String phone, String code, String name, String doctorId) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final response = await api.post('/api/auth/register', data: {
|
||||
'phone': phone, 'smsCode': code, 'role': role,
|
||||
if (inviteCode != null) 'inviteCode': inviteCode,
|
||||
'phone': phone, 'smsCode': code, 'name': name, 'doctorId': doctorId,
|
||||
});
|
||||
final data = response.data['data'];
|
||||
if (data == null) return response.data['message'] ?? '注册失败';
|
||||
@@ -108,7 +107,7 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
await api.saveTokens(data['accessToken'], data['refreshToken']);
|
||||
final user = data['user'];
|
||||
state = AuthState(isLoggedIn: true, isLoading: false,
|
||||
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: role),
|
||||
user: UserInfo(id: user['id'] ?? '', phone: user['phone'] ?? '', role: user['role'] ?? 'User'),
|
||||
);
|
||||
return null;
|
||||
} catch (e) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import '../services/health_service.dart';
|
||||
import '../services/admin_service.dart';
|
||||
|
||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||
return ExerciseService(ref.watch(apiClientProvider));
|
||||
@@ -31,6 +32,10 @@ final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
||||
return ConsultationService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final adminServiceProvider = Provider<AdminService>((ref) {
|
||||
return AdminService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
/// 最新健康数据 Provider
|
||||
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(healthServiceProvider);
|
||||
|
||||
38
health_app/lib/services/admin_service.dart
Normal file
38
health_app/lib/services/admin_service.dart
Normal file
@@ -0,0 +1,38 @@
|
||||
import '../core/api_client.dart';
|
||||
|
||||
class AdminService {
|
||||
final ApiClient _api;
|
||||
AdminService(this._api);
|
||||
|
||||
Future<Map<String, dynamic>> getDoctors() async {
|
||||
final res = await _api.get('/api/admin/doctors');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> addDoctor(Map<String, dynamic> data) async {
|
||||
final res = await _api.post('/api/admin/doctors', data: data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> updateDoctor(String id, Map<String, dynamic> data) async {
|
||||
final res = await _api.put('/api/admin/doctors/$id', data: data);
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> toggleDoctorActive(String id) async {
|
||||
final res = await _api.put('/api/admin/doctors/$id/disable');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> deleteDoctor(String id) async {
|
||||
final res = await _api.delete('/api/admin/doctors/$id');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> getPatients({String? search, int page = 1, int pageSize = 20}) async {
|
||||
final query = <String, dynamic>{'page': page, 'pageSize': pageSize};
|
||||
if (search != null && search.isNotEmpty) query['search'] = search;
|
||||
final res = await _api.get('/api/admin/patients', queryParameters: query);
|
||||
return res.data;
|
||||
}
|
||||
}
|
||||
172
health_app/lib/widgets/admin_drawer.dart
Normal file
172
health_app/lib/widgets/admin_drawer.dart
Normal file
@@ -0,0 +1,172 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../pages/admin/admin_home_page.dart' show adminPageProvider;
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class AdminDrawer extends ConsumerWidget {
|
||||
const AdminDrawer({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final currentPage = ref.watch(adminPageProvider);
|
||||
|
||||
return Drawer(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Container(
|
||||
color: AppColors.background,
|
||||
child: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
margin: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.admin_panel_settings,
|
||||
size: 28,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'系统管理员',
|
||||
style: TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
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: () {
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool selected;
|
||||
final bool danger;
|
||||
const _MenuItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.selected = false,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = danger
|
||||
? AppColors.error
|
||||
: selected
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
child: Material(
|
||||
color: selected ? AppColors.primarySoft : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primaryLight : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 20),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_theme.dart';
|
||||
|
||||
/// 统一样式的清新卡片
|
||||
class AppCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
@@ -10,6 +9,7 @@ class AppCard extends StatelessWidget {
|
||||
final VoidCallback? onTap;
|
||||
final Color? backgroundColor;
|
||||
final BorderRadius? borderRadius;
|
||||
final Border? border;
|
||||
|
||||
const AppCard({
|
||||
super.key,
|
||||
@@ -19,28 +19,30 @@ class AppCard extends StatelessWidget {
|
||||
this.onTap,
|
||||
this.backgroundColor,
|
||||
this.borderRadius,
|
||||
this.border,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
final radius = borderRadius ?? BorderRadius.circular(AppTheme.rLg);
|
||||
final card = Container(
|
||||
margin: margin ?? const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sSm),
|
||||
margin:
|
||||
margin ??
|
||||
const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.sLg,
|
||||
vertical: AppTheme.sSm,
|
||||
),
|
||||
padding: padding,
|
||||
decoration: BoxDecoration(
|
||||
color: backgroundColor ?? theme.colorScheme.card,
|
||||
borderRadius: borderRadius ?? BorderRadius.circular(AppTheme.rMd),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
color: backgroundColor ?? AppColors.cardBackground,
|
||||
borderRadius: radius,
|
||||
border: border ?? Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
return card;
|
||||
if (onTap == null) return card;
|
||||
return InkWell(onTap: onTap, borderRadius: radius, child: card);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../core/app_colors.dart';
|
||||
|
||||
/// 统一空状态占位组件
|
||||
class AppEmptyState extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -18,7 +17,6 @@ class AppEmptyState extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = ShadTheme.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(48),
|
||||
@@ -29,21 +27,34 @@ class AppEmptyState extends StatelessWidget {
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.muted,
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 36, color: theme.colorScheme.mutedForeground),
|
||||
child: Icon(icon, size: 34, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(title, style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground)),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(subtitle!, style: TextStyle(fontSize: 17, color: theme.colorScheme.mutedForeground), textAlign: TextAlign.center),
|
||||
],
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
action!,
|
||||
Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
if (action != null) ...[const SizedBox(height: 20), action!],
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_theme.dart';
|
||||
|
||||
/// 统一菜单项,profile_page 和 settings_pages 都共用这个
|
||||
class AppMenuItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -26,42 +23,68 @@ class AppMenuItem extends StatelessWidget {
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: Colors.black.withAlpha(6), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(icon, size: 21, color: AppColors.primary),
|
||||
),
|
||||
child: Icon(icon, size: 22, color: AppColors.textPrimary),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(subtitle!, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (trailing != null && trailing!.isNotEmpty)
|
||||
Text(trailing!, style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint),
|
||||
]),
|
||||
if (trailing != null && trailing!.isNotEmpty)
|
||||
Text(
|
||||
trailing!,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 20,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ import 'package:flutter/material.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_theme.dart';
|
||||
|
||||
/// 统一样式的标签/筛选 Chip
|
||||
class AppTabChip extends StatelessWidget {
|
||||
final String label;
|
||||
final bool selected;
|
||||
@@ -19,20 +18,26 @@ class AppTabChip extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.only(right: AppTheme.sSm),
|
||||
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sSm),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? Colors.white : AppColors.iconBg,
|
||||
color: selected ? AppColors.primary : Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
border: selected ? Border.all(color: Color(0xFFC8DDFD), width: 1.5) : null,
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primary : AppColors.border,
|
||||
),
|
||||
boxShadow: selected
|
||||
? AppColors.buttonShadow
|
||||
: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: selected ? AppColors.primary : AppColors.textSecondary,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: selected ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,56 +12,165 @@ class DoctorDrawer extends ConsumerWidget {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final auth = ref.watch(authProvider);
|
||||
final currentPage = ref.watch(doctorPageProvider);
|
||||
final name = auth.user?.name ?? '医生';
|
||||
|
||||
return Drawer(
|
||||
width: MediaQuery.of(context).size.width * 0.8,
|
||||
child: Container(
|
||||
color: Colors.white,
|
||||
color: AppColors.background,
|
||||
child: SafeArea(
|
||||
child: Column(children: [
|
||||
// 医生信息头部
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 16),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
child: Column(
|
||||
children: [
|
||||
_DrawerHeader(
|
||||
name: name,
|
||||
subtitle: '点击完善医生信息',
|
||||
icon: Icons.medical_services_outlined,
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
pushRoute(ref, 'doctorProfile');
|
||||
},
|
||||
),
|
||||
child: Column(children: [
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
pushRoute(ref, 'doctorProfile');
|
||||
},
|
||||
child: CircleAvatar(
|
||||
radius: 30,
|
||||
backgroundColor: Colors.white24,
|
||||
child: Text(
|
||||
(auth.user?.name ?? '医生')[0],
|
||||
style: const TextStyle(fontSize: 24, color: Colors.white, fontWeight: FontWeight.w600),
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DrawerHeader extends StatelessWidget {
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
const _DrawerHeader({
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 26),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(auth.user?.name ?? '未设置姓名', style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Text('点击完善信息', style: TextStyle(color: Colors.white70, fontSize: 12)),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
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(),
|
||||
_DrawerItem(icon: Icons.settings_outlined, label: '设置', selected: false, onTap: () { Navigator.pop(context); pushRoute(ref, 'doctorSettings'); }),
|
||||
_DrawerItem(icon: Icons.logout, label: '退出登录', selected: false, onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
goRoute(ref, 'login');
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
]),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -72,16 +181,28 @@ class _DrawerItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final bool selected;
|
||||
final bool danger;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DrawerItem({required this.icon, required this.label, required this.selected, required this.onTap});
|
||||
const _DrawerItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.selected,
|
||||
required this.onTap,
|
||||
this.danger = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = danger
|
||||
? AppColors.error
|
||||
: selected
|
||||
? AppColors.primary
|
||||
: AppColors.textPrimary;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
child: Material(
|
||||
color: selected ? AppColors.doctorBlue.withOpacity(0.08) : Colors.transparent,
|
||||
color: selected ? AppColors.primarySoft : Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
@@ -90,13 +211,26 @@ class _DrawerItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: selected ? AppColors.doctorBlue.withOpacity(0.15) : const Color(0xFFF0F0F0), width: 1),
|
||||
border: Border.all(
|
||||
color: selected ? AppColors.primaryLight : AppColors.border,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: color),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(children: [
|
||||
Icon(icon, size: 20, color: selected ? AppColors.doctorBlue : AppColors.textHint),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: selected ? AppColors.doctorBlue : AppColors.textPrimary))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../core/app_theme.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_theme.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
@@ -15,82 +15,252 @@ class HealthDrawer extends ConsumerWidget {
|
||||
final auth = ref.watch(authProvider);
|
||||
final user = auth.user;
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
return Drawer(
|
||||
width: MediaQuery.of(context).size.width * 0.85,
|
||||
child: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.drawerGradient),
|
||||
color: AppColors.background,
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 18),
|
||||
children: [
|
||||
// 个人信息 + 蓝牙 + 健康档案 — 合并为一个卡片
|
||||
_buildTopCard(user, ref),
|
||||
const SizedBox(height: 8),
|
||||
_buildHealthSection(latestHealth, ref),
|
||||
const SizedBox(height: 8),
|
||||
_buildFeatureSection(ref),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.workspace_premium_rounded, size: 16, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text('VIP服务', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
]),
|
||||
_ProfileCard(user: user, ref: ref),
|
||||
const SizedBox(height: 12),
|
||||
_QuickEntryGrid(ref: ref),
|
||||
const SizedBox(height: 12),
|
||||
_HealthSection(latestHealth: latestHealth, ref: ref),
|
||||
const SizedBox(height: 12),
|
||||
_FeatureSection(ref: ref),
|
||||
const SizedBox(height: 12),
|
||||
_Panel(
|
||||
title: 'VIP服务',
|
||||
icon: Icons.workspace_premium_rounded,
|
||||
child: const ServicePackageCard(compact: true),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Panel(
|
||||
title: '保险',
|
||||
icon: Icons.shield_rounded,
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 4, 16, 16),
|
||||
child: Text(
|
||||
'即将上线,敬请期待',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const ServicePackageCard(compact: true),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
// 保险栏
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.shield_rounded, size: 16, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text('保险', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Padding(
|
||||
padding: EdgeInsets.fromLTRB(16, 0, 16, 14),
|
||||
child: Text('即将上线,敬请期待', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ProfileCard extends StatelessWidget {
|
||||
final dynamic user;
|
||||
final WidgetRef ref;
|
||||
const _ProfileCard({required this.user, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = user?.name ?? '未设置昵称';
|
||||
final phone = user?.phone ?? '未登录';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_outline,
|
||||
color: Colors.white,
|
||||
size: 28,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
phone,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () => pushRoute(ref, 'settings'),
|
||||
icon: const Icon(
|
||||
Icons.settings_outlined,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickEntryGrid extends StatelessWidget {
|
||||
final WidgetRef ref;
|
||||
const _QuickEntryGrid({required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _QuickEntry(
|
||||
icon: Icons.bluetooth_rounded,
|
||||
label: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _QuickEntry(
|
||||
icon: Icons.folder_outlined,
|
||||
label: '健康档案',
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _QuickEntry extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
const _QuickEntry({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(icon, size: 25, color: AppColors.primary),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HealthSection extends StatelessWidget {
|
||||
final AsyncValue<Map<String, dynamic>> latestHealth;
|
||||
final WidgetRef ref;
|
||||
const _HealthSection({required this.latestHealth, required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Panel(
|
||||
title: '健康仪表盘',
|
||||
icon: Icons.dashboard_rounded,
|
||||
trailing: GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
child: const Text(
|
||||
'详情',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
child: latestHealth.when(
|
||||
data: (data) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
_MiniMetric(
|
||||
Icons.favorite_rounded,
|
||||
'血压',
|
||||
_bpText(data['BloodPressure']),
|
||||
() =>
|
||||
pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||||
),
|
||||
_MiniMetric(
|
||||
Icons.monitor_heart_outlined,
|
||||
'心率',
|
||||
_metricVal(data['HeartRate']),
|
||||
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
|
||||
),
|
||||
_MiniMetric(
|
||||
Icons.bloodtype_outlined,
|
||||
'血糖',
|
||||
_metricVal(data['Glucose']),
|
||||
() => pushRoute(ref, 'trend', params: {'type': 'glucose'}),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.all(20),
|
||||
child: Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
),
|
||||
error: (_, __) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(
|
||||
children: [
|
||||
_MiniMetric(Icons.favorite_rounded, '血压', '--'),
|
||||
_MiniMetric(Icons.monitor_heart_outlined, '心率', '--'),
|
||||
_MiniMetric(Icons.bloodtype_outlined, '血糖', '--'),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -98,222 +268,159 @@ class HealthDrawer extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════ 顶部区域(个人信息 + 设置 + 快捷入口,无框融入背景) ═══════════
|
||||
Widget _buildTopCard(dynamic user, WidgetRef ref) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
child: Column(children: [
|
||||
// 第一行:个人信息(窄)+ 设置按钮
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 8, 0),
|
||||
child: Row(children: [
|
||||
// 用户头像+名称 — 占70%
|
||||
Expanded(
|
||||
flex: 7,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 44, height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.person, size: 24, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary), overflow: TextOverflow.ellipsis),
|
||||
Text(user?.phone ?? '未登录', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
|
||||
])),
|
||||
]),
|
||||
),
|
||||
),
|
||||
// 设置按钮 — 占30%
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'settings'),
|
||||
child: Container(
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||
const Icon(Icons.settings_outlined, size: 22, color: AppColors.primary),
|
||||
const SizedBox(height: 2),
|
||||
const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.primary)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
// 第二行:蓝牙 + 健康档案 — 白色底
|
||||
Row(children: [
|
||||
Expanded(child: _QuickEntry(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: _QuickEntry(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _QuickEntry(IconData icon, String label, VoidCallback onTap) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||
),
|
||||
child: Column(children: [
|
||||
Icon(icon, size: 28, color: AppColors.primary),
|
||||
const SizedBox(height: 6),
|
||||
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════ 健康仪表盘 ════════════
|
||||
Widget _buildHealthSection(AsyncValue<Map<String, dynamic>> latestHealth, WidgetRef ref) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.dashboard_rounded, size: 16, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text('健康仪表盘', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
]),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 13, color: AppColors.textHint))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
latestHealth.when(
|
||||
data: (data) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(children: [
|
||||
_MiniMetric(Icons.favorite_rounded, '血压', _bpText(data['BloodPressure']), () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||
_MiniMetric(Icons.monitor_heart_outlined, '心率', _metricVal(data['HeartRate']), () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||
_MiniMetric(Icons.bloodtype_outlined, '血糖', _metricVal(data['Glucose']), () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||
_MiniMetric(Icons.air_outlined, '血氧', _metricVal(data['SpO2']), () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||
_MiniMetric(Icons.monitor_weight_outlined, '体重', _metricVal(data['Weight']), () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
||||
]),
|
||||
),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)))),
|
||||
error: (_, __) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(children: [
|
||||
_MiniMetric(Icons.favorite_rounded, '血压', '--'),
|
||||
_MiniMetric(Icons.monitor_heart_outlined, '心率', '--'),
|
||||
_MiniMetric(Icons.bloodtype_outlined, '血糖', '--'),
|
||||
_MiniMetric(Icons.air_outlined, '血氧', '--'),
|
||||
_MiniMetric(Icons.monitor_weight_outlined, '体重', '--'),
|
||||
]),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════ 功能区 ════════════
|
||||
Widget _buildFeatureSection(WidgetRef ref) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.primaryGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
boxShadow: AppColors.buttonShadow,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.apps_rounded, size: 16, color: Colors.white),
|
||||
SizedBox(width: 4),
|
||||
Text('功能', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 6, 12, 14),
|
||||
child: Row(children: [
|
||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')),
|
||||
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')),
|
||||
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', onTap: () => pushRoute(ref, 'dietRecords')),
|
||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
// ════════════ Helpers ════════════
|
||||
String _bpText(dynamic bp) {
|
||||
if (bp == null) return '--';
|
||||
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
||||
if (bp is Map)
|
||||
return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
||||
return '--';
|
||||
}
|
||||
|
||||
String _metricVal(dynamic val) {
|
||||
if (val == null) return '--';
|
||||
if (val is num) return val.toStringAsFixed(val is double ? 1 : 0);
|
||||
if (val is Map) return (val['value']?.toString() ?? '--');
|
||||
if (val is Map) return val['value']?.toString() ?? '--';
|
||||
return '--';
|
||||
}
|
||||
}
|
||||
|
||||
class _FeatureSection extends StatelessWidget {
|
||||
final WidgetRef ref;
|
||||
const _FeatureSection({required this.ref});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Panel(
|
||||
title: '功能',
|
||||
icon: Icons.apps_rounded,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FeatureChip(
|
||||
icon: Icons.description_outlined,
|
||||
label: '报告管理',
|
||||
onTap: () => pushRoute(ref, 'reports'),
|
||||
),
|
||||
_FeatureChip(
|
||||
icon: Icons.calendar_today_outlined,
|
||||
label: '健康日历',
|
||||
onTap: () => pushRoute(ref, 'calendar'),
|
||||
),
|
||||
_FeatureChip(
|
||||
icon: Icons.restaurant_outlined,
|
||||
label: '饮食记录',
|
||||
onTap: () => pushRoute(ref, 'dietRecords'),
|
||||
),
|
||||
_FeatureChip(
|
||||
icon: Icons.event_note_outlined,
|
||||
label: '复查随访',
|
||||
onTap: () => pushRoute(ref, 'followups'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Panel extends StatelessWidget {
|
||||
final String title;
|
||||
final IconData icon;
|
||||
final Widget child;
|
||||
final Widget? trailing;
|
||||
const _Panel({
|
||||
required this.title,
|
||||
required this.icon,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 14, 14, 6),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Icon(icon, size: 17, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
child,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MiniMetric extends StatelessWidget {
|
||||
final IconData icon; final String label; final String value; final VoidCallback? onTap;
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final VoidCallback? onTap;
|
||||
const _MiniMetric(this.icon, this.label, this.value, [this.onTap]);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.bgGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 22, color: AppColors.primary),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
value,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 11, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 24, color: AppColors.primary),
|
||||
const SizedBox(height: 5),
|
||||
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary)),
|
||||
Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -321,25 +428,41 @@ class _MiniMetric extends StatelessWidget {
|
||||
}
|
||||
|
||||
class _FeatureChip extends StatelessWidget {
|
||||
final IconData icon; final String label; final VoidCallback onTap;
|
||||
const _FeatureChip({required this.icon, required this.label, required this.onTap});
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
const _FeatureChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 2),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.bgGradient,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 22, color: AppColors.primary),
|
||||
const SizedBox(height: 4),
|
||||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
|
||||
]),
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: 92,
|
||||
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 21, color: AppColors.primary),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -145,14 +145,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.12"
|
||||
dev_build:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: dev_build
|
||||
sha256: f2742f154e484a52471dcc7eda17e6da06ab8b1d0a64b4845710d5de4fa9cc47
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.7+4"
|
||||
dio:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@@ -661,14 +653,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
native_toolchain_c:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: native_toolchain_c
|
||||
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.19.1"
|
||||
node_preamble:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -837,14 +821,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
process_run:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: process_run
|
||||
sha256: e0e24c11a49445c449911daef46cf28d68e80a5e33daafbfbc6d6819a1f83519
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3+1"
|
||||
pub_semver:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1002,22 +978,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
sqflite_common_ffi:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqflite_common_ffi
|
||||
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0+3"
|
||||
sqflite_common_ffi_web:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: sqflite_common_ffi_web
|
||||
sha256: "79338d0b69521d70cea10f841209ac87ce617921aaf7d33e7380682c83da1f06"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
sqflite_darwin:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1034,14 +994,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.0"
|
||||
sqlite3:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: sqlite3
|
||||
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.2"
|
||||
sse:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -20,7 +20,6 @@ dependencies:
|
||||
|
||||
# 本地数据库
|
||||
sqflite: ^2.4.0
|
||||
sqflite_common_ffi_web: ^1.1.1
|
||||
path: ^1.9.0
|
||||
|
||||
# 图表
|
||||
@@ -55,3 +54,4 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/images/
|
||||
- assets/branding/
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 917 B |
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 8.1 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 5.5 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
@@ -1,38 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<!--
|
||||
If you are serving your web app in a path other than the root, change the
|
||||
href value below to reflect the base path you are serving from.
|
||||
|
||||
The path provided below has to start and end with a slash "/" in order for
|
||||
it to work correctly.
|
||||
|
||||
For more details:
|
||||
* https://developer.mozilla.org/en-US/docs/Web/HTML/Element/base
|
||||
|
||||
This is a placeholder for base href that will be replaced by the value of
|
||||
the `--base-href` argument provided to `flutter build`.
|
||||
-->
|
||||
<base href="$FLUTTER_BASE_HREF">
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta content="IE=Edge" http-equiv="X-UA-Compatible">
|
||||
<meta name="description" content="A new Flutter project.">
|
||||
|
||||
<!-- iOS meta tags & icons -->
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black">
|
||||
<meta name="apple-mobile-web-app-title" content="health_app">
|
||||
<link rel="apple-touch-icon" href="icons/Icon-192.png">
|
||||
|
||||
<!-- Favicon -->
|
||||
<link rel="icon" type="image/png" href="favicon.png"/>
|
||||
|
||||
<title>health_app</title>
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"name": "health_app",
|
||||
"short_name": "health_app",
|
||||
"start_url": ".",
|
||||
"display": "standalone",
|
||||
"background_color": "#0175C2",
|
||||
"theme_color": "#0175C2",
|
||||
"description": "A new Flutter project.",
|
||||
"orientation": "portrait-primary",
|
||||
"prefer_related_applications": false,
|
||||
"icons": [
|
||||
{
|
||||
"src": "icons/Icon-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
},
|
||||
{
|
||||
"src": "icons/Icon-maskable-512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user