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:
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: [
|
||||
|
||||
Reference in New Issue
Block a user