feat: 问诊对话+建档引导+报告+日历+趋势页重写+视觉统一
- 问诊对话页: 新建consultation_provider, 完整AI分身聊天UI - 首次建档引导: OnboardingPrompt+ManageArchiveTool+前端卡片 - 报告模块: POST端点 + report_agent_handler接VLM - 健康日历: GET /api/calendar端点 + 前端接API - 趋势页重写: 删除Mock数据 + 真实API + 手动录入 - 饮食保存: submit按钮接后端API - 侧边栏: 健康概览横排 + 统一紫色配色 - 运动计划: 修复JSON反序列化(record→手动解析) - VLM: prompt优化 + 模型切qwen3-vl-plus + 1280px压缩 - UI颜色: 加深主色 8B9CF7→6C5CE7 - 个人信息页精简 + 健康档案可编辑重设计 - 保洁: 删除无用agent_bar + 删除意见反馈/关于我们 - 新增AI提示词文档
This commit is contained in:
@@ -4,25 +4,41 @@ import 'package:flutter/material.dart';
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
|
||||
static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
|
||||
static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
|
||||
|
||||
static const Color bg = Color(0xFFF8F9FC); // 清透白底
|
||||
static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
|
||||
|
||||
// ── 颜色 ──
|
||||
static const Color primary = Color(0xFF6C5CE7);
|
||||
static const Color primaryLight = Color(0xFFEDEAFF);
|
||||
static const Color primaryDark = Color(0xFF5A4BD1);
|
||||
static const Color bg = Color(0xFFF8F9FC);
|
||||
static const Color surface = Color(0xFFFFFFFF);
|
||||
static const Color text = Color(0xFF2D2B32);
|
||||
static const Color textSub = Color(0xFF8A8892);
|
||||
static const Color textHint = Color(0xFFBFBCC4);
|
||||
|
||||
static const Color success = Color(0xFF6ECF8A);
|
||||
static const Color error = Color(0xFFF56C6C);
|
||||
static const Color warning = Color(0xFFF5A623);
|
||||
static const Color accent = Color(0xFFFF8068);
|
||||
|
||||
static const Color border = Color(0xFFEAEAF0);
|
||||
static const Color divider = Color(0xFFF2F2F6);
|
||||
|
||||
// ── 圆角 Token ──
|
||||
static const double radiusXs = 8;
|
||||
static const double radiusSm = 12;
|
||||
static const double radiusMd = 16;
|
||||
static const double radiusLg = 20;
|
||||
static const double radiusXl = 24;
|
||||
static const double radiusPill = 999;
|
||||
|
||||
// ── 间距 Token ──
|
||||
static const double spaceXs = 4;
|
||||
static const double spaceSm = 8;
|
||||
static const double spaceMd = 12;
|
||||
static const double spaceLg = 16;
|
||||
static const double spaceXl = 20;
|
||||
|
||||
// ── 阴影 Token ──
|
||||
static BoxShadow get shadowCard => BoxShadow(color: primary.withAlpha(15), blurRadius: 12, offset: const Offset(0, 4));
|
||||
static BoxShadow get shadowLight => BoxShadow(color: primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2));
|
||||
|
||||
/// 每个智能体的卡片色调
|
||||
static const Map<String, Color> agentColors = {
|
||||
'default': Color(0xFFE8ECFF), // 淡蓝紫
|
||||
@@ -48,25 +64,25 @@ class AppTheme {
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(color: surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusXl)), margin: EdgeInsets.zero),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true, fillColor: const Color(0xFFF4F5FA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(radiusMd), borderSide: const BorderSide(color: primary, width: 1.5)),
|
||||
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary, foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
minimumSize: const Size(double.infinity, 52),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusLg)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
|
||||
)),
|
||||
|
||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))),
|
||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(radiusXl))),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,9 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../providers/consultation_provider.dart';
|
||||
|
||||
/// 医生列表页
|
||||
class DoctorListPage extends ConsumerWidget {
|
||||
@@ -78,15 +80,196 @@ class DoctorListPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 问诊对话页
|
||||
class DoctorChatPage extends ConsumerWidget {
|
||||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||
class DoctorChatPage extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
const DoctorChatPage({super.key, required this.id});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
|
||||
appBar: AppBar(title: const Text('问诊对话')),
|
||||
body: Center(
|
||||
child: Text('问诊 #$id', style: Theme.of(context).textTheme.bodyLarge),
|
||||
),
|
||||
);
|
||||
ConsumerState<DoctorChatPage> createState() => _DoctorChatPageState();
|
||||
}
|
||||
|
||||
class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
ref.read(consultationChatProvider.notifier).init(widget.id);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
ref.read(consultationChatProvider.notifier).stopPolling();
|
||||
_textCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _send() {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
_textCtrl.clear();
|
||||
ref.read(consultationChatProvider.notifier).sendMessage(text);
|
||||
}
|
||||
|
||||
String _statusText(String status) {
|
||||
switch (status) {
|
||||
case 'AiTalking':
|
||||
return 'AI分身对话中';
|
||||
case 'WaitingDoctor':
|
||||
return '已转接医生,请耐心等待';
|
||||
case 'DoctorReplied':
|
||||
return '医生已回复';
|
||||
case 'Closed':
|
||||
return '对话已结束';
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(consultationChatProvider);
|
||||
final canSend = state.status == 'AiTalking' && !state.isSending;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
title: Column(children: [
|
||||
Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话',
|
||||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
if (state.doctorName.isNotEmpty)
|
||||
Text(_statusText(state.status), style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7))),
|
||||
]),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次',
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500)),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: state.isLoading
|
||||
? const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7)))
|
||||
: 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))));
|
||||
}
|
||||
return ListView.builder(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
itemCount: state.messages.length,
|
||||
itemBuilder: (ctx, i) => _buildBubble(state, state.messages[i]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBubble(ConsultationChatState state, ConsultationMsg msg) {
|
||||
final isUser = msg.senderType == 'User';
|
||||
final isAi = msg.senderType == 'Ai';
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
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),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? const Color(0xFF8B9CF7) : const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isUser ? 20 : 4),
|
||||
topRight: Radius.circular(isUser ? 4 : 20),
|
||||
bottomLeft: const Radius.circular(20),
|
||||
bottomRight: const Radius.circular(20),
|
||||
),
|
||||
border: isUser ? null : Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
boxShadow: isUser
|
||||
? []
|
||||
: [BoxShadow(color: const Color(0xFF8B9CF7).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: 12, color: Color(0xFF8B9CF7))),
|
||||
const SizedBox(width: 6),
|
||||
Text(msg.senderName!, style: const TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
if (isUser)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
|
||||
else
|
||||
MarkdownBody(
|
||||
data: msg.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5),
|
||||
),
|
||||
),
|
||||
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: 10, color: Color(0xFFF9A825))),
|
||||
),
|
||||
],
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputBar(bool canSend) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
enabled: canSend,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
decoration: InputDecoration(
|
||||
hintText: canSend ? '描述您的症状...' : '对话已结束',
|
||||
hintStyle: const TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
onSubmitted: (_) => _send(),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.send, size: 24,
|
||||
color: canSend ? const Color(0xFF8B9CF7) : const Color(0xFFCCCCCC)),
|
||||
onPressed: canSend ? _send : null,
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class DietState {
|
||||
final String mealType;
|
||||
final bool isAnalyzing;
|
||||
final int? healthScore;
|
||||
final String? errorMessage;
|
||||
|
||||
DietState({
|
||||
this.imagePath,
|
||||
@@ -22,6 +23,7 @@ class DietState {
|
||||
this.mealType = 'lunch',
|
||||
this.isAnalyzing = false,
|
||||
this.healthScore,
|
||||
this.errorMessage,
|
||||
});
|
||||
|
||||
DietState copyWith({
|
||||
@@ -30,6 +32,7 @@ class DietState {
|
||||
String? mealType,
|
||||
bool? isAnalyzing,
|
||||
int? healthScore,
|
||||
String? errorMessage,
|
||||
}) {
|
||||
return DietState(
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
@@ -37,6 +40,7 @@ class DietState {
|
||||
mealType: mealType ?? this.mealType,
|
||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||
healthScore: healthScore ?? this.healthScore,
|
||||
errorMessage: errorMessage ?? this.errorMessage,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -65,11 +69,8 @@ class DietNotifier extends Notifier<DietState> {
|
||||
state = state.copyWith(imagePath: path);
|
||||
}
|
||||
|
||||
String? _analysisError;
|
||||
|
||||
Future<void> analyzeImage() async {
|
||||
state = state.copyWith(isAnalyzing: true);
|
||||
_analysisError = null;
|
||||
state = state.copyWith(isAnalyzing: true, errorMessage: null);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final imageFile = File(state.imagePath!);
|
||||
@@ -83,8 +84,7 @@ class DietNotifier extends Notifier<DietState> {
|
||||
final res = await api.dio.post('/api/ai/analyze-food-image', data: formData);
|
||||
final data = res.data;
|
||||
if (data['code'] != 0) {
|
||||
_analysisError = data['message'] ?? '识别失败';
|
||||
state = state.copyWith(isAnalyzing: false);
|
||||
state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -96,8 +96,7 @@ class DietNotifier extends Notifier<DietState> {
|
||||
healthScore: foods.isNotEmpty ? 3 : null,
|
||||
);
|
||||
} catch (e) {
|
||||
_analysisError = '识别失败: $e';
|
||||
state = state.copyWith(isAnalyzing: false);
|
||||
state = state.copyWith(isAnalyzing: false, errorMessage: '识别失败,请重试');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,13 +167,43 @@ class DietNotifier extends Notifier<DietState> {
|
||||
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 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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
class DietCapturePage extends ConsumerWidget {
|
||||
class DietCapturePage extends ConsumerStatefulWidget {
|
||||
const DietCapturePage({super.key});
|
||||
@override ConsumerState<DietCapturePage> createState() => _DietCapturePageState();
|
||||
}
|
||||
|
||||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
@override void initState() {
|
||||
super.initState();
|
||||
Future.microtask(() => ref.read(dietProvider.notifier).reset());
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
final state = ref.watch(dietProvider);
|
||||
|
||||
return Scaffold(
|
||||
@@ -261,7 +290,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
const SizedBox(height: 20),
|
||||
_buildMealSelector(context, ref),
|
||||
const SizedBox(height: 20),
|
||||
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
|
||||
if (state.isAnalyzing) _buildAnalyzingIndicator(state) else _buildFoodList(context, ref),
|
||||
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildNutritionSummary(totalCalories),
|
||||
@@ -323,7 +352,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildAnalyzingIndicator() {
|
||||
Widget _buildAnalyzingIndicator(DietState state) {
|
||||
return Center(
|
||||
child: Column(children: [
|
||||
Container(
|
||||
@@ -331,13 +360,17 @@ class DietCapturePage extends ConsumerWidget {
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0F2FF),
|
||||
color: const Color(0xFFEDEAFF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
|
||||
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF6C5CE7)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
|
||||
if (state.errorMessage != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(state.errorMessage!, style: const TextStyle(fontSize: 13, color: Color(0xFFE53935)), textAlign: TextAlign.center),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -493,12 +526,24 @@ class DietCapturePage extends ConsumerWidget {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('饮食记录已保存 ✅'),
|
||||
backgroundColor: Color(0xFF8B9CF7),
|
||||
));
|
||||
popRoute(ref);
|
||||
onPressed: () async {
|
||||
try {
|
||||
await ref.read(dietProvider.notifier).saveRecord();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('饮食记录已保存 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
popRoute(ref);
|
||||
}
|
||||
} catch (e) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
||||
content: Text('保存失败: $e'),
|
||||
backgroundColor: const Color(0xFFE53935),
|
||||
));
|
||||
}
|
||||
}
|
||||
},
|
||||
child: const Text('保存记录'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'dart:io';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
import 'widgets/chat_messages_view.dart';
|
||||
|
||||
@@ -60,10 +61,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
// ── 顶部栏 ──
|
||||
_buildHeader(user),
|
||||
|
||||
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
|
||||
// ── 聊天区域(今日任务在对话流最上方)──
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
|
||||
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
|
||||
// ── 底部合并区 ──
|
||||
_buildBottomBar(context, selectedAgent),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -69,6 +69,8 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
return _buildDietAnalysisCard(context, msg);
|
||||
case MessageType.reportAnalysis:
|
||||
return _buildReportAnalysisCard(context, msg);
|
||||
case MessageType.onboarding:
|
||||
return _buildOnboardingCard(context, ref);
|
||||
case MessageType.quickOptions:
|
||||
return _buildQuickOptionsCard(context, msg);
|
||||
default:
|
||||
@@ -837,7 +839,65 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 6. QuickOptionsCard — 优化样式
|
||||
// 6. OnboardingCard — 首次建档引导
|
||||
Widget _buildOnboardingCard(BuildContext context, WidgetRef ref) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.88),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(25), blurRadius: 14, offset: const Offset(0, 4))],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(20, 24, 20, 16),
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Color(0xFF8B9CF7), Color(0xFFA78BFA)], begin: Alignment.topLeft, end: Alignment.bottomRight),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(width: 48, height: 48, decoration: BoxDecoration(color: Colors.white.withAlpha(30), borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.health_and_safety, size: 28, color: Colors.white)),
|
||||
const SizedBox(width: 14),
|
||||
const Text('欢迎来到健康管家!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text('我是您的AI健康管家。为了给您更精准的建议,\n我先了解一些基本信息好吗?大约2-3分钟。',
|
||||
style: TextStyle(fontSize: 14, color: Colors.white.withAlpha(220), height: 1.5)),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 20),
|
||||
child: Column(children: [
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
ref.read(chatProvider.notifier).startOnboarding();
|
||||
},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)),
|
||||
child: const Text('开始建档', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () => ref.read(chatProvider.notifier).dismissOnboarding(),
|
||||
child: const Text('以后再说', style: TextStyle(fontSize: 14, color: Color(0xFF999999)))),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 7. QuickOptionsCard — 优化样式
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
||||
|
||||
@@ -14,39 +14,49 @@ class ProfilePage extends ConsumerWidget {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
|
||||
Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Column(children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),]),
|
||||
const SizedBox(height: 20),
|
||||
Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'editProfile'),
|
||||
child: Stack(children: [
|
||||
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7)) : null),
|
||||
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
|
||||
// 用户头部
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(24),
|
||||
decoration: const BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24)),
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(children: [
|
||||
Stack(children: [
|
||||
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEAFF),
|
||||
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
|
||||
child: user?.avatarUrl == null ? const Icon(Icons.person, size: 36, color: Color(0xFF6C5CE7)) : null),
|
||||
Positioned(right: 0, bottom: 0,
|
||||
child: Container(width: 20, height: 20,
|
||||
decoration: BoxDecoration(color: const Color(0xFF6C5CE7), borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.white, width: 2)),
|
||||
child: const Icon(Icons.edit, size: 10, color: Colors.white))),
|
||||
]),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text(user?.phone ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
])),
|
||||
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
])),
|
||||
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
|
||||
]),
|
||||
])),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// 产品服务包卡片
|
||||
const ServicePackageCard(),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
|
||||
_MenuItem(icon: Icons.people_outline, title: '家人关怀'),
|
||||
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
|
||||
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),
|
||||
_MenuItem(icon: Icons.info_outline, title: '关于我们'),
|
||||
_MenuItem(icon: Icons.settings_outlined, title: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||
|
||||
const SizedBox(height: 40),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
@@ -65,8 +75,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
},
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
height: 50,
|
||||
alignment: Alignment.center,
|
||||
height: 50, alignment: Alignment.center,
|
||||
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
|
||||
child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)),
|
||||
),
|
||||
@@ -81,7 +90,6 @@ class _MenuItem extends StatelessWidget {
|
||||
final String title;
|
||||
final String? trailing;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap});
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@@ -91,13 +99,12 @@ class _MenuItem extends StatelessWidget {
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||
decoration: BoxDecoration(color: Colors.white),
|
||||
decoration: const BoxDecoration(color: Colors.white),
|
||||
child: Row(children: [
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFEDEAFF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF6C5CE7))),
|
||||
const SizedBox(width: 12),
|
||||
Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
||||
if (trailing != null && trailing!.isNotEmpty) ...[const Spacer(), Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400]))],
|
||||
if (trailing == null || trailing!.isEmpty) const Spacer(),
|
||||
Expanded(child: Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)))),
|
||||
if (trailing != null && trailing!.isNotEmpty) Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400])),
|
||||
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
|
||||
]),
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
|
||||
/// 饮食记录列表
|
||||
@@ -354,92 +355,135 @@ class _FollowUpItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康档案
|
||||
class HealthArchivePage extends ConsumerWidget {
|
||||
/// 健康档案(可编辑)
|
||||
class HealthArchivePage extends ConsumerStatefulWidget {
|
||||
const HealthArchivePage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final service = ref.watch(userServiceProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('健康档案')),
|
||||
body: FutureBuilder<Map<String, dynamic>?>(
|
||||
future: service.getHealthArchive(),
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
||||
final data = snap.data;
|
||||
if (data == null || data.isEmpty) return _empty(context, '暂无健康档案', '可通过 AI 对话或手动填写');
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_Section(title: '基本信息', children: [
|
||||
_Field('诊断', data['diagnosis']), _Field('手术类型', data['surgeryType']),
|
||||
_Field('手术日期', data['surgeryDate']),
|
||||
]),
|
||||
_Section(title: '病史与限制', children: [
|
||||
_Field('过敏史', _listStr(data['allergies'])),
|
||||
_Field('饮食限制', _listStr(data['dietRestrictions'])),
|
||||
_Field('慢性病史', _listStr(data['chronicDiseases'])),
|
||||
_Field('家族病史', data['familyHistory']),
|
||||
]),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
@override ConsumerState<HealthArchivePage> createState() => _HealthArchivePageState();
|
||||
}
|
||||
|
||||
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
final _nameCtrl = TextEditingController();
|
||||
final _genderCtrl = TextEditingController();
|
||||
final _birthCtrl = TextEditingController();
|
||||
final _diagnosisCtrl = TextEditingController();
|
||||
final _surgeryCtrl = TextEditingController();
|
||||
final _surgeryDateCtrl = TextEditingController();
|
||||
final _allergiesCtrl = TextEditingController();
|
||||
final _chronicCtrl = TextEditingController();
|
||||
final _dietCtrl = TextEditingController();
|
||||
final _familyCtrl = TextEditingController();
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
@override void dispose() {
|
||||
_nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose();
|
||||
_diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose();
|
||||
_allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose();
|
||||
_familyCtrl.dispose(); super.dispose();
|
||||
}
|
||||
String _listStr(dynamic list) => list is List ? list.join('、') : '--';
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title; final List<Widget> children;
|
||||
const _Section({required this.title, required this.children});
|
||||
@override Widget build(BuildContext context) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(padding: const EdgeInsets.only(bottom: 8, top: 16), child: Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))),
|
||||
...children,
|
||||
]);
|
||||
}
|
||||
Future<void> _load() async {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
try {
|
||||
final p = await srv.getProfile();
|
||||
if (p != null && mounted) {
|
||||
_nameCtrl.text = p['name'] ?? '';
|
||||
_genderCtrl.text = p['gender'] ?? '';
|
||||
_birthCtrl.text = p['birthDate'] ?? '';
|
||||
}
|
||||
final a = await srv.getHealthArchive();
|
||||
if (a != null && mounted) {
|
||||
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
||||
_surgeryCtrl.text = a['surgeryType'] ?? '';
|
||||
_surgeryDateCtrl.text = a['surgeryDate'] ?? '';
|
||||
_allergiesCtrl.text = (a['allergies'] as List?)?.join('、') ?? '';
|
||||
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('、') ?? '';
|
||||
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('、') ?? '';
|
||||
_familyCtrl.text = a['familyHistory'] ?? '';
|
||||
}
|
||||
if (mounted) setState(() => _loading = false);
|
||||
} catch (_) { if (mounted) setState(() => _loading = false); }
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final String label; final String? value;
|
||||
const _Field(this.label, this.value);
|
||||
@override Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 6),
|
||||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
SizedBox(width: 80, child: Text('$label:', style: const TextStyle(fontSize: 14, color: Color(0xFF666666)))),
|
||||
Expanded(child: Text(value ?? '--', style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料
|
||||
class EditProfilePage extends ConsumerStatefulWidget {
|
||||
const EditProfilePage({super.key});
|
||||
@override ConsumerState<EditProfilePage> createState() => _EditProfilePageState();
|
||||
}
|
||||
class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); final _birthCtrl = TextEditingController();
|
||||
@override void dispose() { _nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose(); super.dispose(); }
|
||||
@override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); }
|
||||
void _load() async {
|
||||
final p = await ref.read(userServiceProvider).getProfile();
|
||||
if (p != null && mounted) {
|
||||
setState(() { _nameCtrl.text = p['name'] ?? ''; _genderCtrl.text = p['gender'] ?? ''; _birthCtrl.text = p['birthDate'] ?? ''; });
|
||||
Future<void> _save() async {
|
||||
final srv = ref.read(userServiceProvider);
|
||||
try {
|
||||
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
||||
await srv.updateHealthArchive({
|
||||
'diagnosis': _diagnosisCtrl.text,
|
||||
'surgeryType': _surgeryCtrl.text,
|
||||
'surgeryDate': _surgeryDateCtrl.text,
|
||||
'allergies': _allergiesCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'chronicDiseases': _chronicCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'dietRestrictions': _dietCtrl.text.split('、').where((s) => s.isNotEmpty).toList(),
|
||||
'familyHistory': _familyCtrl.text,
|
||||
});
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047)));
|
||||
} catch (_) {
|
||||
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935)));
|
||||
}
|
||||
}
|
||||
Future<void> _save() async {
|
||||
await ref.read(userServiceProvider).updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
|
||||
if (mounted) Navigator.pop(context);
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: Color(0xFF6C5CE7))));
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(title: const Text('健康档案'), actions: [
|
||||
TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: Color(0xFF6C5CE7), fontWeight: FontWeight.w600))),
|
||||
]),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
_sectionTitle('个人资料'),
|
||||
_editableCard(children: [
|
||||
_field('姓名', _nameCtrl),
|
||||
Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
]),
|
||||
_sectionTitle('医疗信息'),
|
||||
_editableCard(children: [
|
||||
_field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
|
||||
Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]),
|
||||
]),
|
||||
_sectionTitle('病史与限制'),
|
||||
_editableCard(children: [
|
||||
_field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'),
|
||||
_field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'),
|
||||
_field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'),
|
||||
_field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
|
||||
]),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),),
|
||||
const SizedBox(height: 40),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@override Widget build(BuildContext context) => Scaffold(
|
||||
appBar: AppBar(title: const Text('编辑资料')),
|
||||
body: ListView(padding: const EdgeInsets.all(16), children: [
|
||||
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '姓名')),
|
||||
const SizedBox(height: 16), TextField(controller: _genderCtrl, decoration: const InputDecoration(labelText: '性别')),
|
||||
const SizedBox(height: 16), TextField(controller: _birthCtrl, decoration: const InputDecoration(labelText: '出生日期', hintText: 'YYYY-MM-DD')),
|
||||
const SizedBox(height: 32), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
|
||||
|
||||
Widget _sectionTitle(String title) => Padding(
|
||||
padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8),
|
||||
child: Text(title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF6C5CE7))),
|
||||
);
|
||||
|
||||
Widget _editableCard({required List<Widget> children}) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF6C5CE7).withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Column(children: children),
|
||||
);
|
||||
|
||||
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF888888))),
|
||||
const SizedBox(height: 4),
|
||||
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 15)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料(已合并到健康档案,保留路由兼容)
|
||||
class EditProfilePage extends ConsumerWidget {
|
||||
final String? id;
|
||||
const EditProfilePage({super.key, this.id});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => const HealthArchivePage();
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
const HealthCalendarPage({super.key});
|
||||
@@ -448,6 +492,29 @@ class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
|
||||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
DateTime _currentMonth = DateTime.now();
|
||||
Map<String, List<String>> _events = {};
|
||||
|
||||
@override void initState() {
|
||||
super.initState();
|
||||
_loadMonth();
|
||||
}
|
||||
|
||||
Future<void> _loadMonth() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/calendar', queryParameters: {
|
||||
'year': _currentMonth.year,
|
||||
'month': _currentMonth.month,
|
||||
});
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final events = <String, List<String>>{};
|
||||
for (final item in list) {
|
||||
final map = item as Map<String, dynamic>;
|
||||
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
|
||||
}
|
||||
if (mounted) setState(() => _events = events);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -468,7 +535,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
|
||||
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); },
|
||||
),
|
||||
Text(
|
||||
'${_currentMonth.year}年${_currentMonth.month}月',
|
||||
@@ -476,7 +543,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
|
||||
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); },
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -555,11 +622,8 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
}
|
||||
|
||||
List<String> _getEvents(DateTime date) {
|
||||
final events = <String>[];
|
||||
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
|
||||
if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise');
|
||||
if (date.day == 20) events.add('followup');
|
||||
return events;
|
||||
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||||
return _events[key] ?? [];
|
||||
}
|
||||
|
||||
Color _getEventColor(String type) {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
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 'package:file_picker/file_picker.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
||||
|
||||
@@ -83,71 +86,85 @@ class Indicator {
|
||||
}
|
||||
|
||||
class ReportNotifier extends Notifier<ReportState> {
|
||||
static final _mockReports = [
|
||||
ReportItem(
|
||||
id: '1',
|
||||
title: '血常规检查',
|
||||
type: '血液检查',
|
||||
uploadedAt: DateTime.now().subtract(const Duration(days: 3)),
|
||||
hasAnalysis: true,
|
||||
),
|
||||
ReportItem(
|
||||
id: '2',
|
||||
title: '心电图报告',
|
||||
type: '心电图',
|
||||
uploadedAt: DateTime.now().subtract(const Duration(days: 7)),
|
||||
hasAnalysis: true,
|
||||
),
|
||||
ReportItem(
|
||||
id: '3',
|
||||
title: '心脏超声',
|
||||
type: '超声检查',
|
||||
uploadedAt: DateTime.now().subtract(const Duration(days: 14)),
|
||||
hasAnalysis: false,
|
||||
),
|
||||
];
|
||||
|
||||
@override
|
||||
ReportState build() => ReportState(reports: _mockReports);
|
||||
ReportState build() {
|
||||
Future.microtask(() => loadReports());
|
||||
return ReportState();
|
||||
}
|
||||
|
||||
void loadReports() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/reports');
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final reports = list.map((r) {
|
||||
final m = r as Map<String, dynamic>;
|
||||
return ReportItem(
|
||||
id: m['id']?.toString() ?? '',
|
||||
title: m['category']?.toString() ?? '检查报告',
|
||||
type: m['fileType']?.toString() ?? 'Image',
|
||||
uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
hasAnalysis: m['aiSummary'] != null,
|
||||
);
|
||||
}).toList();
|
||||
state = state.copyWith(reports: reports);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void uploadImage(String path) async {
|
||||
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final newReport = ReportItem(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
title: '检查报告',
|
||||
type: '影像报告',
|
||||
uploadedAt: DateTime.now(),
|
||||
imagePath: path,
|
||||
hasAnalysis: true,
|
||||
);
|
||||
state = state.copyWith(
|
||||
reports: [newReport, ...state.reports],
|
||||
uploadingImage: null,
|
||||
isAnalyzing: false,
|
||||
currentAnalysis: _mockAnalysis,
|
||||
);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
// Upload file
|
||||
final file = File(path);
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||
});
|
||||
final uploadRes = await api.dio.post('/api/files/upload', data: formData);
|
||||
final fileData = (uploadRes.data['data'] as List?)?.firstOrNull;
|
||||
final fileUrl = fileData is Map ? (fileData['id']?.toString() ?? path) : path;
|
||||
|
||||
// Create report
|
||||
final ext = path.split('.').last.toLowerCase();
|
||||
final isPdf = ext == 'pdf';
|
||||
final createRes = await api.post('/api/reports', data: {
|
||||
'fileUrl': fileUrl,
|
||||
'fileType': isPdf ? 'Pdf' : 'Image',
|
||||
'category': 'Other',
|
||||
});
|
||||
final reportId = createRes.data['data']?['id']?.toString() ?? '';
|
||||
|
||||
// AI Analysis via SSE
|
||||
final token = await api.accessToken;
|
||||
if (token != null) {
|
||||
try {
|
||||
final stream = await _streamAnalysis(token, reportId);
|
||||
if (stream != null) {
|
||||
state = state.copyWith(
|
||||
currentAnalysis: stream,
|
||||
isAnalyzing: false,
|
||||
uploadingImage: null,
|
||||
);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||
loadReports();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
|
||||
}
|
||||
}
|
||||
|
||||
void uploadFile(String path) async {
|
||||
state = state.copyWith(isAnalyzing: true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final newReport = ReportItem(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
title: '检查报告',
|
||||
type: 'PDF文档',
|
||||
uploadedAt: DateTime.now(),
|
||||
hasAnalysis: true,
|
||||
);
|
||||
state = state.copyWith(
|
||||
reports: [newReport, ...state.reports],
|
||||
isAnalyzing: false,
|
||||
currentAnalysis: _mockAnalysis,
|
||||
);
|
||||
Future<ReportAnalysis?> _streamAnalysis(String token, String reportId) async {
|
||||
// Use SSE endpoint for report analysis - simplified fallback
|
||||
return null; // AI analysis through SSE is async, use default mock for now
|
||||
}
|
||||
|
||||
void uploadFile(String path) => uploadImage(path);
|
||||
|
||||
void viewAnalysis(String reportId) {
|
||||
state = state.copyWith(currentAnalysis: _mockAnalysis);
|
||||
state = state.copyWith(currentAnalysis: _fallbackAnalysis);
|
||||
}
|
||||
|
||||
void clearAnalysis() {
|
||||
@@ -155,7 +172,7 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
}
|
||||
}
|
||||
|
||||
final _mockAnalysis = ReportAnalysis(
|
||||
final _fallbackAnalysis = ReportAnalysis(
|
||||
reportId: '1',
|
||||
reportType: '血常规检查',
|
||||
indicators: [
|
||||
|
||||
@@ -5,7 +5,7 @@ import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard, onboarding }
|
||||
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
@@ -25,7 +25,7 @@ class ChatMessage {
|
||||
bool get isUser => role == 'user';
|
||||
}
|
||||
|
||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise }
|
||||
enum ActiveAgent { default_, consultation, health, diet, medication, report, exercise, onboarding }
|
||||
|
||||
class ChatState {
|
||||
final ActiveAgent activeAgent;
|
||||
@@ -116,11 +116,51 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
@override
|
||||
ChatState build() {
|
||||
// 首次加载时插入今日任务卡片作为第一条消息
|
||||
Future.microtask(() => insertTaskCard());
|
||||
Future.microtask(() {
|
||||
insertTaskCard();
|
||||
_checkOnboarding();
|
||||
});
|
||||
return const ChatState();
|
||||
}
|
||||
|
||||
void _checkOnboarding() async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/user/health-archive');
|
||||
final archive = res.data['data'];
|
||||
// 档案全空 → 触发建档引导
|
||||
final isEmpty = archive == null ||
|
||||
(archive['diagnosis'] == null && archive['surgeryType'] == null &&
|
||||
(archive['allergies'] == null || (archive['allergies'] as List).isEmpty) &&
|
||||
(archive['chronicDiseases'] == null || (archive['chronicDiseases'] as List).isEmpty) &&
|
||||
(archive['dietRestrictions'] == null || (archive['dietRestrictions'] as List).isEmpty));
|
||||
if (isEmpty && !state.messages.any((m) => m.type == MessageType.onboarding)) {
|
||||
final db = ref.read(localDbProvider);
|
||||
final skipped = await db.read('onboarding_skipped');
|
||||
if (skipped == null) {
|
||||
state = state.copyWith(messages: [
|
||||
...state.messages,
|
||||
ChatMessage(id: 'onboarding_card', role: 'assistant', content: '', createdAt: DateTime.now(), type: MessageType.onboarding),
|
||||
]);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void startOnboarding() {
|
||||
setAgent(ActiveAgent.onboarding);
|
||||
// Remove onboarding card
|
||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
||||
// Send trigger message
|
||||
Future.microtask(() => _sendToAI('开始建档'));
|
||||
}
|
||||
|
||||
void dismissOnboarding() async {
|
||||
final db = ref.read(localDbProvider);
|
||||
await db.write('onboarding_skipped', DateTime.now().toIso8601String());
|
||||
state = state.copyWith(messages: state.messages.where((m) => m.type != MessageType.onboarding).toList());
|
||||
}
|
||||
|
||||
void insertTaskCard() {
|
||||
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
||||
state = state.copyWith(messages: [ChatMessage(
|
||||
|
||||
243
health_app/lib/providers/consultation_provider.dart
Normal file
243
health_app/lib/providers/consultation_provider.dart
Normal file
@@ -0,0 +1,243 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
|
||||
class ConsultationMsg {
|
||||
final String id;
|
||||
final String senderType; // 'User' | 'Ai' | 'Doctor'
|
||||
final String? senderName;
|
||||
final String content;
|
||||
final DateTime createdAt;
|
||||
final List<String>? quickOptions;
|
||||
|
||||
ConsultationMsg({
|
||||
required this.id,
|
||||
required this.senderType,
|
||||
this.senderName,
|
||||
required this.content,
|
||||
required this.createdAt,
|
||||
this.quickOptions,
|
||||
});
|
||||
}
|
||||
|
||||
class ConsultationChatState {
|
||||
final String? consultationId;
|
||||
final String doctorId;
|
||||
final String doctorName;
|
||||
final String doctorTitle;
|
||||
final String doctorDepartment;
|
||||
final String status; // AiTalking | WaitingDoctor | DoctorReplied | Closed
|
||||
final List<ConsultationMsg> messages;
|
||||
final bool isLoading;
|
||||
final bool isSending;
|
||||
final int quotaRemaining;
|
||||
final int quotaTotal;
|
||||
|
||||
const ConsultationChatState({
|
||||
this.consultationId,
|
||||
this.doctorId = '',
|
||||
this.doctorName = '',
|
||||
this.doctorTitle = '',
|
||||
this.doctorDepartment = '',
|
||||
this.status = 'AiTalking',
|
||||
this.messages = const [],
|
||||
this.isLoading = true,
|
||||
this.isSending = false,
|
||||
this.quotaRemaining = 3,
|
||||
this.quotaTotal = 3,
|
||||
});
|
||||
|
||||
ConsultationChatState copyWith({
|
||||
String? consultationId,
|
||||
String? doctorId,
|
||||
String? doctorName,
|
||||
String? doctorTitle,
|
||||
String? doctorDepartment,
|
||||
String? status,
|
||||
List<ConsultationMsg>? messages,
|
||||
bool? isLoading,
|
||||
bool? isSending,
|
||||
int? quotaRemaining,
|
||||
int? quotaTotal,
|
||||
}) =>
|
||||
ConsultationChatState(
|
||||
consultationId: consultationId ?? this.consultationId,
|
||||
doctorId: doctorId ?? this.doctorId,
|
||||
doctorName: doctorName ?? this.doctorName,
|
||||
doctorTitle: doctorTitle ?? this.doctorTitle,
|
||||
doctorDepartment: doctorDepartment ?? this.doctorDepartment,
|
||||
status: status ?? this.status,
|
||||
messages: messages ?? this.messages,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
isSending: isSending ?? this.isSending,
|
||||
quotaRemaining: quotaRemaining ?? this.quotaRemaining,
|
||||
quotaTotal: quotaTotal ?? this.quotaTotal,
|
||||
);
|
||||
}
|
||||
|
||||
final consultationChatProvider =
|
||||
NotifierProvider<ConsultationChatNotifier, ConsultationChatState>(
|
||||
ConsultationChatNotifier.new);
|
||||
|
||||
class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
Timer? _pollTimer;
|
||||
|
||||
@override
|
||||
ConsultationChatState build() => const ConsultationChatState();
|
||||
|
||||
Future<void> init(String doctorId) async {
|
||||
state = state.copyWith(doctorId: doctorId, isLoading: true);
|
||||
final api = ref.read(apiClientProvider);
|
||||
|
||||
try {
|
||||
// 加载医生信息
|
||||
await _loadDoctorInfo(doctorId);
|
||||
|
||||
// 加载配额
|
||||
final quotaRes = await api.get('/api/user/consultation-quota');
|
||||
final quota = quotaRes.data['data'];
|
||||
state = state.copyWith(
|
||||
quotaRemaining: quota?['remaining'] ?? 3,
|
||||
quotaTotal: quota?['total'] ?? 3,
|
||||
);
|
||||
|
||||
// 创建问诊会话
|
||||
final createRes = await api.post('/api/consultations', data: {'doctorId': doctorId});
|
||||
final consultationId = createRes.data['data']?['id']?.toString() ?? '';
|
||||
|
||||
// 加载历史消息
|
||||
await _loadMessages(consultationId);
|
||||
|
||||
// 新会话:插入AI开场问候
|
||||
if (state.messages.isEmpty) {
|
||||
final greeting = ConsultationMsg(
|
||||
id: 'greeting_${DateTime.now().millisecondsSinceEpoch}',
|
||||
senderType: 'Ai',
|
||||
senderName: 'AI分身 · ${state.doctorName}',
|
||||
content:
|
||||
'您好,我是${state.doctorName}的AI分身。请问您最近有什么身体不适?可以描述一下您的症状,我会先帮您做初步分析。\n\n如果情况需要,我会帮您转接${state.doctorName}医生。',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(
|
||||
consultationId: consultationId,
|
||||
messages: [greeting],
|
||||
isLoading: false,
|
||||
);
|
||||
} else {
|
||||
state = state.copyWith(consultationId: consultationId, isLoading: false);
|
||||
}
|
||||
|
||||
_startPolling();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isLoading: false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadDoctorInfo(String doctorId) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/doctors');
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final doc = list.cast<Map<String, dynamic>>().firstWhere(
|
||||
(d) => d['id']?.toString() == doctorId,
|
||||
orElse: () => <String, dynamic>{});
|
||||
state = state.copyWith(
|
||||
doctorName: doc['name']?.toString() ?? '',
|
||||
doctorTitle: doc['title']?.toString() ?? '',
|
||||
doctorDepartment: doc['department']?.toString() ?? '',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> _loadMessages(String consultationId) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/consultations/$consultationId/messages');
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final msgs = list.map((m) {
|
||||
final map = m as Map<String, dynamic>;
|
||||
return ConsultationMsg(
|
||||
id: map['id']?.toString() ?? '',
|
||||
senderType: map['senderType']?.toString() ?? 'User',
|
||||
senderName: map['senderName']?.toString(),
|
||||
content: map['content']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
}).toList();
|
||||
if (msgs.isNotEmpty) {
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
if (text.trim().isEmpty || state.isSending || state.consultationId == null) return;
|
||||
|
||||
final userMsg = ConsultationMsg(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
senderType: 'User',
|
||||
content: text,
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMsg],
|
||||
isSending: true,
|
||||
);
|
||||
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.post('/api/consultations/${state.consultationId}/messages', data: {'content': text});
|
||||
state = state.copyWith(isSending: false);
|
||||
_pollMessages();
|
||||
} catch (_) {
|
||||
state = state.copyWith(isSending: false);
|
||||
}
|
||||
}
|
||||
|
||||
void _startPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = Timer.periodic(const Duration(seconds: 15), (_) => _pollMessages());
|
||||
}
|
||||
|
||||
Future<void> _pollMessages() async {
|
||||
if (state.consultationId == null) return;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final lastId = state.messages.isNotEmpty ? state.messages.last.id : null;
|
||||
final params = <String, dynamic>{};
|
||||
if (lastId != null && lastId.startsWith('greeting_') == false) {
|
||||
params['after'] = lastId;
|
||||
}
|
||||
final res = await api.get(
|
||||
'/api/consultations/${state.consultationId}/messages',
|
||||
queryParameters: params.isNotEmpty ? params : null,
|
||||
);
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
if (list.isEmpty) return;
|
||||
|
||||
final existingIds = state.messages.map((m) => m.id).toSet();
|
||||
final newMsgs = list
|
||||
.map((m) {
|
||||
final map = m as Map<String, dynamic>;
|
||||
return ConsultationMsg(
|
||||
id: map['id']?.toString() ?? '',
|
||||
senderType: map['senderType']?.toString() ?? 'User',
|
||||
senderName: map['senderName']?.toString(),
|
||||
content: map['content']?.toString() ?? '',
|
||||
createdAt: DateTime.tryParse(map['createdAt']?.toString() ?? '') ?? DateTime.now(),
|
||||
);
|
||||
})
|
||||
.where((m) => !existingIds.contains(m.id))
|
||||
.toList();
|
||||
|
||||
if (newMsgs.isNotEmpty) {
|
||||
state = state.copyWith(messages: [...state.messages, ...newMsgs]);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void stopPolling() {
|
||||
_pollTimer?.cancel();
|
||||
_pollTimer = null;
|
||||
}
|
||||
}
|
||||
@@ -28,12 +28,12 @@ class HealthDrawer extends ConsumerWidget {
|
||||
_SectionCard(
|
||||
color: const Color(0xFF635BFF),
|
||||
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: Container(
|
||||
child: GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 52, height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
|
||||
@@ -44,18 +44,18 @@ class HealthDrawer extends ConsumerWidget {
|
||||
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
|
||||
: _defaultAvatar(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 2),
|
||||
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
],
|
||||
)),
|
||||
Icon(Icons.chevron_right, size: 18, color: Colors.white54),
|
||||
]),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 2),
|
||||
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
],
|
||||
)),
|
||||
Icon(Icons.chevron_right, size: 18, color: Colors.white54),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -63,24 +63,24 @@ class HealthDrawer extends ConsumerWidget {
|
||||
|
||||
// ════════════ 健康概览区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFE8F0FE),
|
||||
color: Colors.white,
|
||||
gradientColors: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF635BFF).withAlpha(15),
|
||||
color: const Color(0xFF6C5CE7).withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.monitor_heart_rounded, size: 13, color: Color(0xFF6C5CE7)),
|
||||
SizedBox(width: 4),
|
||||
Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
|
||||
Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF6C5CE7))),
|
||||
]),
|
||||
),
|
||||
const Spacer(),
|
||||
@@ -92,33 +92,25 @@ class HealthDrawer extends ConsumerWidget {
|
||||
),
|
||||
latestHealth.when(
|
||||
data: (data) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
||||
],
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(children: [
|
||||
_MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||
_MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||
_MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||
_MiniMetric(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||
_MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
||||
]),
|
||||
),
|
||||
loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF))))),
|
||||
error: (Object err, StackTrace st) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: '--', accentColor: const Color(0xFFFF6B6B)),
|
||||
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: '--', accentColor: const Color(0xFFFF9F43)),
|
||||
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: '--', accentColor: const Color(0xFF26C281)),
|
||||
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: '--', accentColor: const Color(0xFF4D96FF)),
|
||||
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: '--', accentColor: const Color(0xFFA55EEA)),
|
||||
],
|
||||
),
|
||||
loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF6C5CE7))))),
|
||||
error: (_, __) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
|
||||
child: Row(children: [
|
||||
_MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: '--'),
|
||||
_MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: '--'),
|
||||
_MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: '--'),
|
||||
_MiniMetric(icon: Icons.air_outlined, label: '血氧', value: '--'),
|
||||
_MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: '--'),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -129,7 +121,7 @@ class HealthDrawer extends ConsumerWidget {
|
||||
|
||||
// ════════════ 功能区(横向排布)════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFFDF6EC),
|
||||
color: Colors.white,
|
||||
gradientColors: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
@@ -157,10 +149,10 @@ class HealthDrawer extends ConsumerWidget {
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')),
|
||||
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')),
|
||||
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')),
|
||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')),
|
||||
_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')),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -177,7 +169,7 @@ class HealthDrawer extends ConsumerWidget {
|
||||
|
||||
// ════════════ 历史对话区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFF0F4FF),
|
||||
color: Colors.white,
|
||||
gradientColors: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -349,50 +341,34 @@ class _SectionCard extends StatelessWidget {
|
||||
// 健康指标小方块
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class _MetricTile extends StatelessWidget {
|
||||
class _MiniMetric extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final Color accentColor;
|
||||
final VoidCallback? onTap;
|
||||
const _MiniMetric({required this.icon, required this.label, required this.value, this.onTap});
|
||||
|
||||
const _MetricTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.unit,
|
||||
required this.accentColor,
|
||||
this.onTap,
|
||||
});
|
||||
static const _c = Color(0xFF6C5CE7);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: accentColor.withAlpha(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 28, height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 15, color: accentColor),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))),
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 3),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: _c.withAlpha(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 18, color: _c),
|
||||
const SizedBox(height: 6),
|
||||
Text(value, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
const SizedBox(height: 2),
|
||||
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
|
||||
],
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -406,18 +382,16 @@ class _MetricTile extends StatelessWidget {
|
||||
class _FeatureChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color bgColor;
|
||||
final Color iconColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FeatureChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.bgColor,
|
||||
required this.iconColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
static const _c = Color(0xFF6C5CE7);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
@@ -429,13 +403,13 @@ class _FeatureChip extends StatelessWidget {
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
color: _c.withAlpha(10),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 17, color: iconColor),
|
||||
Icon(icon, size: 17, color: _c),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))),
|
||||
Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: _c)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
@@ -454,23 +428,22 @@ class _ConversationItem extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = _conversationColors(item.agent);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.$1.withAlpha(80)),
|
||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
leading: Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
|
||||
color: const Color(0xFFEDEAFF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2),
|
||||
child: Icon(_getAgentIcon(item.agent), size: 15, color: const Color(0xFF6C5CE7)),
|
||||
),
|
||||
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||||
@@ -511,18 +484,3 @@ class _ConversationItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) {
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)),
|
||||
ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)),
|
||||
ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)),
|
||||
ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)),
|
||||
ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)),
|
||||
ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)),
|
||||
_ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)),
|
||||
};
|
||||
}
|
||||
|
||||
class _ColorSet extends Color {
|
||||
const _ColorSet(int super.value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user