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:
MingNian
2026-06-16 15:16:44 +08:00
parent b635e9d25f
commit c70d5d4be6
103 changed files with 12284 additions and 13049 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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' => '影像检查报告',
_ => '检查报告',
};

View File

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

View File

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

View File

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

View 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;
}
}

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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