feat: 全功能前后端联调完成,47/47 测试通过
前端: - 新增 DietCapturePage 独立拍照识别页 - 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项) - 任务卡片区:异常警告+数据摘要+自动折叠 - 侧滑抽屉:历史对话列表+对话管理 - 运动计划:进度卡片+创建计划+每日打卡 - 报告页:拍照/相册/PDF上传+分析 - 面板按钮补全血氧/体重录入 - UI 升级:紫色主题+动画+气泡样式 - 全部迁移 Riverpod 3.x API 后端: - 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型 - SSE answer 事件携带 type 字段 - 提示词优化(移除"阿福",语气规则归位) - 运动计划支持 AI 创建和打卡 测试: - 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
This commit is contained in:
448
health_app/lib/pages/diet/diet_capture_page.dart
Normal file
448
health_app/lib/pages/diet/diet_capture_page.dart
Normal file
@@ -0,0 +1,448 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
|
||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||
|
||||
class DietState {
|
||||
final String? imagePath;
|
||||
final List<FoodItem> foods;
|
||||
final String mealType;
|
||||
final bool isAnalyzing;
|
||||
final int? healthScore;
|
||||
|
||||
DietState({
|
||||
this.imagePath,
|
||||
this.foods = const [],
|
||||
this.mealType = 'lunch',
|
||||
this.isAnalyzing = false,
|
||||
this.healthScore,
|
||||
});
|
||||
|
||||
DietState copyWith({
|
||||
String? imagePath,
|
||||
List<FoodItem>? foods,
|
||||
String? mealType,
|
||||
bool? isAnalyzing,
|
||||
int? healthScore,
|
||||
}) {
|
||||
return DietState(
|
||||
imagePath: imagePath ?? this.imagePath,
|
||||
foods: foods ?? this.foods,
|
||||
mealType: mealType ?? this.mealType,
|
||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||
healthScore: healthScore ?? this.healthScore,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class FoodItem {
|
||||
final String id;
|
||||
String name;
|
||||
int calories;
|
||||
bool selected;
|
||||
|
||||
FoodItem({
|
||||
required this.id,
|
||||
required this.name,
|
||||
required this.calories,
|
||||
this.selected = true,
|
||||
});
|
||||
}
|
||||
|
||||
class DietNotifier extends Notifier<DietState> {
|
||||
@override
|
||||
DietState build() => DietState();
|
||||
|
||||
void setImage(String path) {
|
||||
state = state.copyWith(imagePath: path);
|
||||
}
|
||||
|
||||
void analyzeImage() async {
|
||||
state = state.copyWith(isAnalyzing: true);
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
final mockFoods = [
|
||||
FoodItem(id: '1', name: '米饭', calories: 150),
|
||||
FoodItem(id: '2', name: '番茄炒蛋', calories: 200),
|
||||
FoodItem(id: '3', name: '红烧肉', calories: 350),
|
||||
FoodItem(id: '4', name: '青菜', calories: 50),
|
||||
];
|
||||
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3);
|
||||
}
|
||||
|
||||
void updateFoodName(String id, String name) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void updateFoodCalories(String id, int calories) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: calories, selected: f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void toggleFood(String id) {
|
||||
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: f.calories, selected: !f.selected) : f).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void addFood() {
|
||||
final newId = '${DateTime.now().millisecondsSinceEpoch}';
|
||||
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', calories: 100)];
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void removeFood(String id) {
|
||||
final foods = state.foods.where((f) => f.id != id).toList();
|
||||
state = state.copyWith(foods: foods);
|
||||
}
|
||||
|
||||
void setMealType(String type) {
|
||||
state = state.copyWith(mealType: type);
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = DietState();
|
||||
}
|
||||
}
|
||||
|
||||
class DietCapturePage extends ConsumerWidget {
|
||||
const DietCapturePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('拍饮食'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
border: Border.all(color: const Color(0xFF635BFF), width: 2),
|
||||
),
|
||||
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
const SizedBox(height: 40),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
|
||||
const SizedBox(width: 24),
|
||||
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 32, color: const Color(0xFF635BFF)),
|
||||
onPressed: () => _pickImage(context, ref, source),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (picked != null) {
|
||||
ref.read(dietProvider.notifier).setImage(picked.path);
|
||||
ref.read(dietProvider.notifier).analyzeImage();
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(children: [
|
||||
_buildImagePreview(state.imagePath!),
|
||||
const SizedBox(height: 20),
|
||||
_buildMealSelector(context, ref),
|
||||
const SizedBox(height: 20),
|
||||
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
|
||||
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
_buildNutritionSummary(totalCalories),
|
||||
const SizedBox(height: 20),
|
||||
_buildHealthScore(state.healthScore ?? 0),
|
||||
const SizedBox(height: 30),
|
||||
_buildSubmitButton(context, ref),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview(String path) {
|
||||
return Container(
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMealSelector(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
final meals = [
|
||||
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
|
||||
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
|
||||
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
|
||||
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
|
||||
];
|
||||
|
||||
return Column(children: [
|
||||
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: meals.map((meal) {
|
||||
final isSelected = state.mealType == meal['type'];
|
||||
return Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
|
||||
child: Column(children: [
|
||||
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 4),
|
||||
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
|
||||
]),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? const Color(0xFF635BFF) : const Color(0xFFF5F3FF),
|
||||
foregroundColor: isSelected ? Colors.white : const Color(0xFF635BFF),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildAnalyzingIndicator() {
|
||||
return Center(
|
||||
child: Column(children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFoodList(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
),
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
const Text('🍽️', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 20, color: Color(0xFF635BFF)),
|
||||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||
),
|
||||
]),
|
||||
),
|
||||
...state.foods.map((food) => _buildFoodItem(context, ref, food)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: food.selected ? const Color(0xFFF5F3FF) : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(children: [
|
||||
Checkbox(
|
||||
value: food.selected,
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||
activeColor: const Color(0xFF635BFF),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
TextField(
|
||||
decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'),
|
||||
controller: TextEditingController(text: food.name),
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
|
||||
style: const TextStyle(fontSize: 16),
|
||||
),
|
||||
Row(children: [
|
||||
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
SizedBox(
|
||||
width: 60,
|
||||
child: TextField(
|
||||
decoration: const InputDecoration(border: InputBorder.none, hintText: '0'),
|
||||
controller: TextEditingController(text: food.calories.toString()),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
|
||||
style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF)),
|
||||
),
|
||||
),
|
||||
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)),
|
||||
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNutritionSummary(int totalCalories) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)),
|
||||
const SizedBox(width: 12),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
const Spacer(),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
|
||||
const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHealthScore(int score) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
),
|
||||
child: Column(children: [
|
||||
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: List.generate(5, (i) => Icon(
|
||||
Icons.star,
|
||||
size: 36,
|
||||
color: i < score ? const Color(0xFFFFB800) : Colors.grey[200],
|
||||
)),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
String _getScoreComment(int score) {
|
||||
switch (score) {
|
||||
case 1: return '饮食不太健康,建议多吃蔬菜';
|
||||
case 2: return '需要改善,减少油腻食物';
|
||||
case 3: return '还不错,继续保持均衡饮食';
|
||||
case 4: return '很健康!营养搭配合理';
|
||||
case 5: return '非常健康!饮食管理很棒';
|
||||
default: return '请完善食物信息';
|
||||
}
|
||||
}
|
||||
|
||||
Color _getScoreColor(int score) {
|
||||
switch (score) {
|
||||
case 1: return const Color(0xFFE53935);
|
||||
case 2: return const Color(0xFFF9A825);
|
||||
case 3: return const Color(0xFF635BFF);
|
||||
case 4: return const Color(0xFF43A047);
|
||||
case 5: return const Color(0xFF00C853);
|
||||
default: return Colors.grey[400]!;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('饮食记录已保存 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('保存记录'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
textStyle: const TextStyle(fontSize: 16),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import '../../core/api_client.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/agent_bar.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
import 'widgets/chat_messages_view.dart';
|
||||
@@ -21,6 +22,13 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
bool _taskCardsExpanded = true;
|
||||
bool _showExpandButton = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_scrollCtrl.addListener(_onScroll);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@@ -29,6 +37,14 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _onScroll() {
|
||||
if (_scrollCtrl.offset > 50 && !_showExpandButton) {
|
||||
setState(() => _showExpandButton = true);
|
||||
} else if (_scrollCtrl.offset <= 50 && _showExpandButton) {
|
||||
setState(() => _showExpandButton = false);
|
||||
}
|
||||
}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
@@ -44,18 +60,40 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return Scaffold(
|
||||
drawer: const HealthDrawer(),
|
||||
body: SafeArea(
|
||||
child: Column(children: [
|
||||
_buildHeader(context),
|
||||
if (_taskCardsExpanded) _buildTaskCards(chatState),
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
|
||||
const AgentBar(),
|
||||
_buildInputBar(),
|
||||
child: Stack(children: [
|
||||
Column(children: [
|
||||
_buildHeader(context),
|
||||
if (_taskCardsExpanded) _buildTaskCards(),
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
_buildAgentPanel(context, selectedAgent),
|
||||
const AgentBar(),
|
||||
_buildInputBar(),
|
||||
]),
|
||||
_buildExpandButton(),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExpandButton() {
|
||||
if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink();
|
||||
|
||||
return Positioned(
|
||||
top: 60,
|
||||
right: 16,
|
||||
child: AnimatedOpacity(
|
||||
opacity: _showExpandButton ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
child: FloatingActionButton(
|
||||
onPressed: () => setState(() => _taskCardsExpanded = true),
|
||||
mini: true,
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
child: const Icon(Icons.keyboard_arrow_down, size: 20),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
@@ -72,55 +110,289 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCards(ChatState chatState) {
|
||||
return GestureDetector(
|
||||
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); },
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
||||
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
|
||||
),
|
||||
Widget _buildTaskCards() {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
return latestHealth.when(
|
||||
data: (data) {
|
||||
final tasks = _getTaskCards(data);
|
||||
if (tasks.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
||||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Column(children: tasks),
|
||||
]),
|
||||
if (chatState.noticeText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
error: (_, __) {
|
||||
final tasks = _getTaskCards(const {});
|
||||
if (tasks.isEmpty) return const SizedBox.shrink();
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
||||
const SizedBox(width: 8),
|
||||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
||||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Column(children: tasks),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _getGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour < 6) return '夜深了';
|
||||
if (hour < 9) return '早上好';
|
||||
if (hour < 12) return '上午好';
|
||||
if (hour < 14) return '中午好';
|
||||
if (hour < 18) return '下午好';
|
||||
if (hour < 22) return '晚上好';
|
||||
return '夜深了';
|
||||
}
|
||||
|
||||
List<Widget> _getTaskCards(Map<String, dynamic> healthData) {
|
||||
final cards = <Widget>[];
|
||||
|
||||
cards.add(_buildMedicationCard());
|
||||
cards.add(_buildExerciseCard());
|
||||
cards.add(_buildMeasurementCard());
|
||||
|
||||
final abnormalCards = _buildAbnormalCards(healthData);
|
||||
cards.addAll(abnormalCards);
|
||||
|
||||
final summaryCard = _buildSummaryCard(healthData);
|
||||
if (summaryCard != null) cards.add(summaryCard);
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
Widget _buildMedicationCard() {
|
||||
return _buildTaskCard(
|
||||
'💊',
|
||||
'计划 8:00 吃 阿司匹林 100mg',
|
||||
Icons.check_circle_outline,
|
||||
() => _handleMedicationCheck(),
|
||||
type: 'medication',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExerciseCard() {
|
||||
return _buildTaskCard(
|
||||
'🏃',
|
||||
'今日待运动:散步 30 分钟',
|
||||
Icons.check_circle_outline,
|
||||
() => _handleExerciseCheck(),
|
||||
type: 'exercise',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMeasurementCard() {
|
||||
return _buildTaskCard(
|
||||
'🩺',
|
||||
'今日待测量:血压',
|
||||
Icons.arrow_forward_ios,
|
||||
() => _textCtrl.text = '血压 ',
|
||||
type: 'measurement',
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
|
||||
final cards = <Widget>[];
|
||||
|
||||
final bp = healthData['BloodPressure'];
|
||||
if (bp != null && bp is Map) {
|
||||
final systolic = bp['systolic'];
|
||||
final diastolic = bp['diastolic'];
|
||||
if (systolic != null && systolic >= 140) {
|
||||
cards.add(_buildTaskCard(
|
||||
'⚠️',
|
||||
'昨日血压 ${systolic}/${diastolic ?? '--'},偏高',
|
||||
Icons.arrow_forward_ios,
|
||||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||||
type: 'warning',
|
||||
highlight: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
final hr = healthData['HeartRate'];
|
||||
if (hr != null && hr is Map) {
|
||||
final value = hr['value'];
|
||||
if (value != null && (value > 100 || value < 60)) {
|
||||
cards.add(_buildTaskCard(
|
||||
'⚠️',
|
||||
'昨日心率 $value,${value > 100 ? '偏高' : '偏低'}',
|
||||
Icons.arrow_forward_ios,
|
||||
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
|
||||
type: 'warning',
|
||||
highlight: true,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
return cards;
|
||||
}
|
||||
|
||||
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
|
||||
final values = <String>[];
|
||||
|
||||
final bp = healthData['BloodPressure'];
|
||||
if (bp != null && bp is Map) {
|
||||
final sys = bp['systolic'];
|
||||
final dia = bp['diastolic'];
|
||||
if (sys != null && dia != null) values.add('血压 $sys/$dia');
|
||||
}
|
||||
|
||||
final hr = healthData['HeartRate'];
|
||||
if (hr != null && hr is Map && hr['value'] != null) {
|
||||
values.add('心率 ${hr['value']}');
|
||||
}
|
||||
|
||||
final glucose = healthData['Glucose'];
|
||||
if (glucose != null && glucose is Map && glucose['value'] != null) {
|
||||
values.add('血糖 ${glucose['value']}');
|
||||
}
|
||||
|
||||
if (values.isEmpty) return null;
|
||||
|
||||
return _buildTaskCard(
|
||||
'📊',
|
||||
'今日已记录:${values.join('、')}',
|
||||
Icons.arrow_forward_ios,
|
||||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
||||
type: 'summary',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
decoration: highlight ? BoxDecoration(
|
||||
color: const Color(0xFFFDF2F2),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
) : null,
|
||||
child: Row(children: [
|
||||
Text(icon, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Text(text, style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333),
|
||||
))),
|
||||
GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
|
||||
return Container(
|
||||
void _handleMedicationCheck() async {
|
||||
await ref.read(medicationServiceProvider).confirm('');
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已记录服药 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
duration: Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
|
||||
void _handleExerciseCheck() async {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('已完成运动 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
duration: Duration(seconds: 2),
|
||||
));
|
||||
}
|
||||
|
||||
Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) {
|
||||
if (agent == null) return const SizedBox.shrink();
|
||||
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.easeInOut,
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))],
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
_buildAgentPanelHeader(agent),
|
||||
const SizedBox(height: 12),
|
||||
..._getAgentButtons(agent),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAgentPanelHeader(ActiveAgent agent) {
|
||||
final titles = {
|
||||
ActiveAgent.consultation: '🩺 AI 问诊',
|
||||
ActiveAgent.health: '📊 记数据',
|
||||
ActiveAgent.diet: '📸 拍饮食',
|
||||
ActiveAgent.medication: '💊 药管家',
|
||||
ActiveAgent.report: '📋 看报告',
|
||||
ActiveAgent.exercise: '🏃 运动计划',
|
||||
};
|
||||
final tips = {
|
||||
ActiveAgent.consultation: '或直接对我说你的症状',
|
||||
ActiveAgent.health: '或直接对我说:"血压 135/85"',
|
||||
ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"',
|
||||
ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"',
|
||||
ActiveAgent.report: '或直接上传报告图片',
|
||||
ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"',
|
||||
};
|
||||
|
||||
return Column(children: [
|
||||
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
]);
|
||||
}
|
||||
|
||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||
final buttons = <Widget>[];
|
||||
if (agent == ActiveAgent.health) {
|
||||
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
|
||||
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
|
||||
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
|
||||
buttons.add(_panelBtn('手动录入血氧', Icons.air));
|
||||
buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight));
|
||||
} else if (agent == ActiveAgent.diet) {
|
||||
buttons.add(_panelBtn('拍照', Icons.camera_alt));
|
||||
buttons.add(_panelBtn('上传照片', Icons.photo_library));
|
||||
@@ -141,15 +413,16 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _onAgentAction(label),
|
||||
icon: Icon(icon, size: 20),
|
||||
label: Text(label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
icon: Icon(icon, size: 18),
|
||||
label: Text(label, style: const TextStyle(fontSize: 14)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF5F3FF),
|
||||
foregroundColor: const Color(0xFF635BFF),
|
||||
side: const BorderSide(color: Color(0xFF635BFF)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -159,10 +432,10 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
void _onAgentAction(String label) {
|
||||
switch (label) {
|
||||
case '拍照':
|
||||
_pickImage(ImageSource.camera);
|
||||
pushRoute(ref, 'dietCapture');
|
||||
break;
|
||||
case '上传照片':
|
||||
_pickImage(ImageSource.gallery);
|
||||
pushRoute(ref, 'dietCapture');
|
||||
break;
|
||||
case '手动录入血压':
|
||||
_textCtrl.text = '血压 ';
|
||||
@@ -173,6 +446,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
case '手动录入心率':
|
||||
_textCtrl.text = '心率 ';
|
||||
break;
|
||||
case '手动录入血氧':
|
||||
_textCtrl.text = '血氧 ';
|
||||
break;
|
||||
case '手动录入体重':
|
||||
_textCtrl.text = '体重 ';
|
||||
break;
|
||||
case '用药管理':
|
||||
pushRoute(ref, 'medications');
|
||||
break;
|
||||
|
||||
@@ -19,9 +19,19 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.chat_bubble_outline, size: 48, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
width: 80,
|
||||
height: 80,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
borderRadius: BorderRadius.circular(40),
|
||||
),
|
||||
child: const Icon(Icons.health_and_safety, size: 40, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text('开始和 AI 健康管家对话吧', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 8),
|
||||
Text('记录健康数据,获取专业建议', style: TextStyle(fontSize: 14, color: Colors.grey[400])),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -34,48 +44,112 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (context, index) {
|
||||
final msg = messages[messages.length - 1 - index];
|
||||
return _buildMessageBubble(context, msg, chatState);
|
||||
return _buildMessageContent(context, msg, chatState);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMessageBubble(BuildContext context, ChatMessage msg, ChatState chatState) {
|
||||
Widget _buildMessageContent(BuildContext context, ChatMessage msg, ChatState chatState) {
|
||||
final isUser = msg.isUser;
|
||||
|
||||
if (!isUser && chatState.isStreaming && msg.content.isEmpty) {
|
||||
return _buildThinkingBubble(context, chatState.thinkingText);
|
||||
}
|
||||
|
||||
switch (msg.type) {
|
||||
case MessageType.dataConfirm:
|
||||
return _buildDataConfirmCard(context, msg);
|
||||
case MessageType.medicationConfirm:
|
||||
return _buildMedicationConfirmCard(context, msg);
|
||||
case MessageType.dietAnalysis:
|
||||
return _buildDietAnalysisCard(context, msg);
|
||||
case MessageType.reportAnalysis:
|
||||
return _buildReportAnalysisCard(context, msg);
|
||||
case MessageType.quickOptions:
|
||||
return _buildQuickOptionsCard(context, msg);
|
||||
default:
|
||||
return _buildTextBubble(context, msg);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildThinkingBubble(BuildContext context, String? thinkingText) {
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: const BorderRadius.only(topLeft: Radius.circular(4), topRight: Radius.circular(20), bottomLeft: Radius.circular(20), bottomRight: Radius.circular(20)),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: const [BoxShadow(color: Color(0xFF635BFF), blurRadius: 4, offset: Offset(0, 2))],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 24,
|
||||
height: 24,
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
const Text('正在分析...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTextBubble(BuildContext context, ChatMessage msg) {
|
||||
final isUser = msg.isUser;
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.78),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? const Color(0xFF635BFF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isUser ? null : const Border(left: BorderSide(color: Color(0xFF635BFF), width: 3)),
|
||||
color: isUser ? const Color(0xFF635BFF) : 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(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: isUser ? [] : [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
if (!isUser && chatState.isStreaming && msg.content.isEmpty)
|
||||
_buildThinkingIndicator()
|
||||
else if (isUser)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white))
|
||||
if (isUser)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white, height: 1.4))
|
||||
else
|
||||
MarkdownBody(
|
||||
data: msg.content.isEmpty ? '...' : msg.content,
|
||||
data: msg.content,
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A)),
|
||||
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
|
||||
code: TextStyle(fontSize: 14, backgroundColor: Colors.grey[200]),
|
||||
p: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A), height: 1.5),
|
||||
h1: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
|
||||
h2: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)),
|
||||
code: const TextStyle(fontSize: 14, backgroundColor: Colors.grey),
|
||||
),
|
||||
),
|
||||
if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming)
|
||||
if (!isUser && !msg.content.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
'AI 健康管家 · 仅供参考',
|
||||
style: TextStyle(fontSize: 12, color: Colors.grey[400]),
|
||||
),
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(children: [
|
||||
const CircleAvatar(radius: 10, backgroundColor: Color(0xFFEDEBFF), child: Icon(Icons.chat_bubble_outline, size: 14, color: Color(0xFF635BFF))),
|
||||
const SizedBox(width: 6),
|
||||
Text('健康管家', style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
||||
const SizedBox(width: 4),
|
||||
Text('仅供参考', style: TextStyle(fontSize: 11, color: Colors.grey[300])),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -83,14 +157,350 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildThinkingIndicator() {
|
||||
return const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
SizedBox(width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
SizedBox(width: 8),
|
||||
Text('思考中...', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
],
|
||||
Widget _buildDataConfirmCard(BuildContext context, ChatMessage msg) {
|
||||
final meta = msg.metadata;
|
||||
final metricType = meta?['type'] as String? ?? '';
|
||||
final value = meta?['value'] as String? ?? '';
|
||||
final abnormal = meta?['abnormal'] as bool? ?? false;
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.only(topLeft: Radius.circular(20), topRight: Radius.circular(20)),
|
||||
),
|
||||
child: Row(children: [
|
||||
const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('已记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF43A047))),
|
||||
]),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
Text(
|
||||
_getMetricIcon(metricType),
|
||||
style: const TextStyle(fontSize: 24),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(_getMetricName(metricType), style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: abnormal ? const Color(0xFFE53935) : const Color(0xFF1A1A1A))),
|
||||
]),
|
||||
const Spacer(),
|
||||
if (abnormal) const Icon(Icons.warning_amber, size: 20, color: Color(0xFFE53935)),
|
||||
]),
|
||||
if (abnormal)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 12),
|
||||
child: Text('⚠️ 数值超出正常范围,请关注', style: TextStyle(fontSize: 14, color: Color(0xFFE53935))),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('编辑'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF635BFF),
|
||||
side: const BorderSide(color: Color(0xFF635BFF)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('确认'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMedicationConfirmCard(BuildContext context, ChatMessage msg) {
|
||||
final meta = msg.metadata;
|
||||
final name = meta?['name'] as String? ?? '';
|
||||
final dosage = meta?['dosage'] as String? ?? '';
|
||||
final time = meta?['time'] as String? ?? '';
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(children: [
|
||||
Row(children: [
|
||||
const Text('💊', style: TextStyle(fontSize: 28)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
if (time.isNotEmpty) Text('每天 $time', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 16),
|
||||
const Text('需要调整吗?', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Expanded(child: _medBtn('确认', Icons.check, Colors.white, const Color(0xFF635BFF))),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _medBtn('修改时间', Icons.access_time, const Color(0xFF635BFF), Colors.white)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(child: _medBtn('改剂量', Icons.edit, const Color(0xFF635BFF), Colors.white)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _medBtn(String label, IconData icon, Color textColor, Color bgColor) {
|
||||
return ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: Row(children: [Icon(icon, size: 16), const SizedBox(width: 4), Text(label, style: TextStyle(fontSize: 12))]),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: bgColor,
|
||||
foregroundColor: textColor,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDietAnalysisCard(BuildContext context, ChatMessage msg) {
|
||||
final meta = msg.metadata;
|
||||
final foods = meta?['foods'] as List? ?? [];
|
||||
final totalCalories = meta?['totalCalories'] as int? ?? 0;
|
||||
final rating = meta?['rating'] as int? ?? 0;
|
||||
final warnings = meta?['warnings'] as List? ?? [];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('🍽️ 饮食分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Column(children: foods.map((food) {
|
||||
final f = food as Map? ?? {};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(children: [
|
||||
Text(f['name'] as String? ?? '', style: const TextStyle(fontSize: 14)),
|
||||
const Spacer(),
|
||||
Text('${f['calories'] ?? 0} kcal', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
]),
|
||||
);
|
||||
}).toList()),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const Spacer(),
|
||||
Text('$totalCalories kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
const Text('健康评分', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const SizedBox(width: 8),
|
||||
Row(children: List.generate(5, (i) => Icon(Icons.star, size: 16, color: i < rating ? const Color(0xFFFFB800) : Colors.grey[300]))),
|
||||
]),
|
||||
if (warnings.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
...warnings.map((w) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 4),
|
||||
child: Text('⚠️ $w', style: TextStyle(fontSize: 14, color: const Color(0xFFE53935))),
|
||||
)),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
const Text('建议:饮食均衡,多吃蔬菜水果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportAnalysisCard(BuildContext context, ChatMessage msg) {
|
||||
final meta = msg.metadata;
|
||||
final reportType = meta?['type'] as String? ?? '';
|
||||
final indicators = meta?['indicators'] as List? ?? [];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('📋', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
Text(reportType, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
const SizedBox(height: 8),
|
||||
Column(children: indicators.map((ind) {
|
||||
final i = ind as Map? ?? {};
|
||||
final name = i['name'] as String? ?? '';
|
||||
final value = i['value'] as String? ?? '';
|
||||
final status = i['status'] as String? ?? 'normal';
|
||||
Color statusColor;
|
||||
switch (status) {
|
||||
case 'high': statusColor = const Color(0xFFE53935); break;
|
||||
case 'low': statusColor = const Color(0xFFF9A825); break;
|
||||
default: statusColor = const Color(0xFF43A047);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Row(children: [
|
||||
Expanded(child: Text(name, style: const TextStyle(fontSize: 14))),
|
||||
Text(value, style: TextStyle(fontSize: 14, color: statusColor, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(width: 8),
|
||||
Icon(status == 'normal' ? Icons.check_circle : Icons.warning_amber, size: 16, color: statusColor),
|
||||
]),
|
||||
);
|
||||
}).toList()),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF3C7),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text('⚠️ AI 预解读,待医生确认', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: OutlinedButton(
|
||||
onPressed: () {},
|
||||
child: const Text('查看原始图片'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF635BFF),
|
||||
side: const BorderSide(color: Color(0xFF635BFF)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildQuickOptionsCard(BuildContext context, ChatMessage msg) {
|
||||
final meta = msg.metadata;
|
||||
final options = meta?['options'] as List? ?? [];
|
||||
|
||||
return Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.85),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(children: [
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(spacing: 8, runSpacing: 8, children: options.map((opt) {
|
||||
final o = opt as Map? ?? {};
|
||||
return ElevatedButton(
|
||||
onPressed: () {},
|
||||
child: Text(o['label'] as String? ?? '', style: const TextStyle(fontSize: 14)),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFF5F3FF),
|
||||
foregroundColor: const Color(0xFF635BFF),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
),
|
||||
);
|
||||
}).toList()),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getMetricIcon(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'blood_pressure': return '🩺';
|
||||
case 'heart_rate': return '💓';
|
||||
case 'glucose': return '💉';
|
||||
case 'spo2': return '🫁';
|
||||
case 'weight': return '⚖️';
|
||||
default: return '📊';
|
||||
}
|
||||
}
|
||||
|
||||
String _getMetricName(String type) {
|
||||
switch (type.toLowerCase()) {
|
||||
case 'blood_pressure': return '血压';
|
||||
case 'heart_rate': return '心率';
|
||||
case 'glucose': return '血糖';
|
||||
case 'spo2': return '血氧';
|
||||
case 'weight': return '体重';
|
||||
default: return '健康指标';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,39 +45,95 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final plan = ref.watch(currentExercisePlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('运动计划')),
|
||||
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createDefaultPlan(ref),
|
||||
onPressed: () => _createDefaultPlan(ref, context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建本周计划'),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
),
|
||||
body: plan.when(
|
||||
data: (data) {
|
||||
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
|
||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
return ListView.builder(
|
||||
itemCount: items.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final item = items[i];
|
||||
final completedCount = items.where((i) => i['isCompleted'] == true).length;
|
||||
final totalCount = items.where((i) => i['isRestDay'] != true).length;
|
||||
|
||||
return ListView(children: [
|
||||
_buildProgressCard(completedCount, totalCount),
|
||||
const SizedBox(height: 16),
|
||||
...items.asMap().entries.map((entry) {
|
||||
final i = entry.key;
|
||||
final item = entry.value;
|
||||
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
|
||||
final isRest = item['isRestDay'] == true;
|
||||
final isDone = item['isCompleted'] == true;
|
||||
return ListTile(
|
||||
leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey),
|
||||
title: Text('${weekDays[day]} ${isRest ? '休息日' : '${item['exerciseType']} ${item['durationMinutes']}分钟'}'),
|
||||
trailing: isDone ? null : IconButton(icon: const Icon(Icons.check, color: Color(0xFF43A047)), onPressed: () { _checkIn(ref, item['id']); }),
|
||||
return _ExercisePlanItem(
|
||||
day: weekDays[day],
|
||||
dayIndex: day,
|
||||
isRest: isRest,
|
||||
isDone: isDone,
|
||||
exerciseType: item['exerciseType']?.toString() ?? '',
|
||||
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
|
||||
onCheckIn: () => _checkIn(ref, item['id'], context),
|
||||
);
|
||||
},
|
||||
);
|
||||
}),
|
||||
]);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
|
||||
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref) async {
|
||||
Widget _buildProgressCard(int completed, int total) {
|
||||
final progress = total > 0 ? (completed / total * 100).toInt() : 0;
|
||||
return Container(
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(children: [
|
||||
const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 12),
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Center(
|
||||
child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text('已完成 $completed/$total 天', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
height: 8,
|
||||
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)),
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress / 100,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
@@ -92,19 +148,198 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
}
|
||||
|
||||
void _checkIn(WidgetRef ref, String itemId) async {
|
||||
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('打卡成功 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
class _ExercisePlanItem extends StatelessWidget {
|
||||
final String day;
|
||||
final int dayIndex;
|
||||
final bool isRest;
|
||||
final bool isDone;
|
||||
final String exerciseType;
|
||||
final int duration;
|
||||
final VoidCallback onCheckIn;
|
||||
|
||||
const _ExercisePlanItem({
|
||||
required this.day,
|
||||
required this.dayIndex,
|
||||
required this.isRest,
|
||||
required this.isDone,
|
||||
required this.exerciseType,
|
||||
required this.duration,
|
||||
required this.onCheckIn,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final today = DateTime.now().weekday - 1;
|
||||
final isToday = dayIndex == today;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: isDone
|
||||
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
|
||||
: isRest
|
||||
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
|
||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
|
||||
if (isToday) const SizedBox(width: 4),
|
||||
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
if (!isRest && !isDone)
|
||||
ElevatedButton(
|
||||
onPressed: onCheckIn,
|
||||
child: const Text('打卡'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
),
|
||||
),
|
||||
if (isDone)
|
||||
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复查列表
|
||||
class FollowUpListPage extends ConsumerWidget {
|
||||
const FollowUpListPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '复查随访', '暂无复查安排');
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
),
|
||||
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
|
||||
);
|
||||
}
|
||||
|
||||
void _showAddDialog(BuildContext context) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('添加复查提醒'),
|
||||
content: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
TextField(decoration: const InputDecoration(labelText: '医院名称')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '科室')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
|
||||
const SizedBox(height: 12),
|
||||
TextField(decoration: const InputDecoration(labelText: '备注')),
|
||||
]),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('复查提醒已添加 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
},
|
||||
child: const Text('保存'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final _mockFollowUps = [
|
||||
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
|
||||
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
|
||||
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
|
||||
];
|
||||
|
||||
class _FollowUpItem extends StatelessWidget {
|
||||
final Map<String, dynamic> item;
|
||||
const _FollowUpItem({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isCompleted = item['status'] == 'completed';
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(
|
||||
isCompleted ? '已完成' : '待就诊',
|
||||
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
|
||||
],
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 健康档案
|
||||
@@ -194,9 +429,152 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
class HealthCalendarPage extends ConsumerWidget {
|
||||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
const HealthCalendarPage({super.key});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '健康日历', '暂无数据');
|
||||
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
|
||||
}
|
||||
|
||||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
DateTime _currentMonth = DateTime.now();
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('健康日历'), centerTitle: true),
|
||||
body: Column(children: [
|
||||
_buildMonthHeader(),
|
||||
_buildWeekdayHeader(),
|
||||
_buildCalendarGrid(),
|
||||
const SizedBox(height: 16),
|
||||
_buildLegend(),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMonthHeader() {
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_left, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
|
||||
),
|
||||
Text(
|
||||
'${_currentMonth.year}年${_currentMonth.month}月',
|
||||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.chevron_right, size: 32),
|
||||
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildWeekdayHeader() {
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return Row(children: weekdays.map((day) => Expanded(
|
||||
child: Center(child: Text(day, style: TextStyle(fontSize: 14, color: Colors.grey[500]))),
|
||||
)).toList());
|
||||
}
|
||||
|
||||
Widget _buildCalendarGrid() {
|
||||
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
|
||||
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
|
||||
final daysInMonth = lastDay.day;
|
||||
final startWeekday = firstDay.weekday % 7;
|
||||
|
||||
final days = List.generate(42, (i) {
|
||||
final dayIndex = i - startWeekday;
|
||||
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
|
||||
return dayIndex + 1;
|
||||
});
|
||||
|
||||
return Expanded(
|
||||
child: GridView.builder(
|
||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
|
||||
itemCount: 42,
|
||||
itemBuilder: (ctx, i) {
|
||||
final day = days[i];
|
||||
if (day == null) return const SizedBox();
|
||||
return _buildDayCell(day);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDayCell(int day) {
|
||||
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
|
||||
final today = DateTime.now();
|
||||
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
|
||||
final events = _getEvents(date);
|
||||
|
||||
return Container(
|
||||
decoration: isToday ? BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
) : null,
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'$day',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: isToday ? Colors.white : Colors.black,
|
||||
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
if (events.isNotEmpty)
|
||||
Positioned(
|
||||
bottom: 4,
|
||||
child: Row(children: events.map((type) => Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: _getEventColor(type),
|
||||
borderRadius: BorderRadius.circular(3),
|
||||
),
|
||||
)).toList()),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Color _getEventColor(String type) {
|
||||
switch (type) {
|
||||
case 'medication': return const Color(0xFF635BFF);
|
||||
case 'exercise': return const Color(0xFF43A047);
|
||||
case 'followup': return const Color(0xFFF59E0B);
|
||||
default: return Colors.grey;
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildLegend() {
|
||||
final items = [
|
||||
{'color': const Color(0xFF635BFF), 'label': '用药提醒'},
|
||||
{'color': const Color(0xFF43A047), 'label': '运动计划'},
|
||||
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
|
||||
];
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
|
||||
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
|
||||
const SizedBox(width: 4),
|
||||
Text(item['label'] as String, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
|
||||
const SizedBox(width: 20),
|
||||
])).toList()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 静态文本页
|
||||
|
||||
@@ -1,25 +1,485 @@
|
||||
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';
|
||||
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
|
||||
|
||||
class ReportState {
|
||||
final List<ReportItem> reports;
|
||||
final String? uploadingImage;
|
||||
final bool isAnalyzing;
|
||||
final ReportAnalysis? currentAnalysis;
|
||||
|
||||
ReportState({
|
||||
this.reports = const [],
|
||||
this.uploadingImage,
|
||||
this.isAnalyzing = false,
|
||||
this.currentAnalysis,
|
||||
});
|
||||
|
||||
ReportState copyWith({
|
||||
List<ReportItem>? reports,
|
||||
String? uploadingImage,
|
||||
bool? isAnalyzing,
|
||||
ReportAnalysis? currentAnalysis,
|
||||
}) {
|
||||
return ReportState(
|
||||
reports: reports ?? this.reports,
|
||||
uploadingImage: uploadingImage ?? this.uploadingImage,
|
||||
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
|
||||
currentAnalysis: currentAnalysis ?? this.currentAnalysis,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ReportItem {
|
||||
final String id;
|
||||
final String title;
|
||||
final String type;
|
||||
final DateTime uploadedAt;
|
||||
final String? imagePath;
|
||||
final bool hasAnalysis;
|
||||
|
||||
ReportItem({
|
||||
required this.id,
|
||||
required this.title,
|
||||
required this.type,
|
||||
required this.uploadedAt,
|
||||
this.imagePath,
|
||||
this.hasAnalysis = false,
|
||||
});
|
||||
}
|
||||
|
||||
class ReportAnalysis {
|
||||
final String reportId;
|
||||
final String reportType;
|
||||
final List<Indicator> indicators;
|
||||
final String summary;
|
||||
|
||||
ReportAnalysis({
|
||||
required this.reportId,
|
||||
required this.reportType,
|
||||
required this.indicators,
|
||||
required this.summary,
|
||||
});
|
||||
}
|
||||
|
||||
class Indicator {
|
||||
final String name;
|
||||
final String value;
|
||||
final String unit;
|
||||
final String status;
|
||||
final String? referenceRange;
|
||||
|
||||
Indicator({
|
||||
required this.name,
|
||||
required this.value,
|
||||
required this.unit,
|
||||
required this.status,
|
||||
this.referenceRange,
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
void viewAnalysis(String reportId) {
|
||||
state = state.copyWith(currentAnalysis: _mockAnalysis);
|
||||
}
|
||||
|
||||
void clearAnalysis() {
|
||||
state = state.copyWith(currentAnalysis: null);
|
||||
}
|
||||
}
|
||||
|
||||
final _mockAnalysis = ReportAnalysis(
|
||||
reportId: '1',
|
||||
reportType: '血常规检查',
|
||||
indicators: [
|
||||
Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'),
|
||||
Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'),
|
||||
Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'),
|
||||
Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'),
|
||||
Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'),
|
||||
Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'),
|
||||
],
|
||||
summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。',
|
||||
);
|
||||
|
||||
/// 报告列表页
|
||||
class ReportListPage extends ConsumerWidget {
|
||||
const ReportListPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '暂无报告', '可到「看报告」上传');
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(reportProvider);
|
||||
|
||||
if (state.isAnalyzing) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('看报告')),
|
||||
body: const Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircularProgressIndicator(color: Color(0xFF635BFF)),
|
||||
SizedBox(height: 16),
|
||||
Text('AI 正在分析报告...'),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('看报告'),
|
||||
centerTitle: true,
|
||||
),
|
||||
floatingActionButton: _buildUploadButton(context, ref),
|
||||
body: state.reports.isEmpty
|
||||
? _buildEmptyState(context)
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: state.reports.length,
|
||||
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUploadButton(BuildContext context, WidgetRef ref) {
|
||||
return FloatingActionButton(
|
||||
onPressed: () => _showUploadOptions(context, ref),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
child: const Icon(Icons.add),
|
||||
);
|
||||
}
|
||||
|
||||
void _showUploadOptions(BuildContext context, WidgetRef ref) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('拍照上传'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
|
||||
if (picked != null) {
|
||||
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
|
||||
if (picked != null) {
|
||||
ref.read(reportProvider.notifier).uploadImage(picked.path);
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.file_open),
|
||||
title: const Text('上传PDF文件'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']);
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
ref.read(reportProvider.notifier).uploadFile(result.files.first.path!);
|
||||
}
|
||||
},
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmptyState(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: const Icon(Icons.file_open, size: 48, color: Color(0xFF635BFF)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
const SizedBox(height: 8),
|
||||
const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: _getReportIcon(report.type),
|
||||
),
|
||||
title: Text(report.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
|
||||
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
||||
]),
|
||||
trailing: report.hasAnalysis
|
||||
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
|
||||
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
|
||||
onTap: () {
|
||||
ref.read(reportProvider.notifier).viewAnalysis(report.id);
|
||||
pushRoute(ref, 'reportDetail', params: {'id': report.id});
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _getReportIcon(String type) {
|
||||
final icons = {
|
||||
'血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF635BFF)),
|
||||
'心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF635BFF)),
|
||||
'超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)),
|
||||
'影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)),
|
||||
'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF635BFF)),
|
||||
};
|
||||
return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF635BFF));
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(date);
|
||||
if (diff.inDays == 0) return '今天';
|
||||
if (diff.inDays == 1) return '昨天';
|
||||
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||||
return '${date.month}月${date.day}日';
|
||||
}
|
||||
}
|
||||
|
||||
/// 报告详情页
|
||||
class ReportDetailPage extends ConsumerWidget {
|
||||
final String id;
|
||||
const ReportDetailPage({super.key, required this.id});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '报告详情', '报告 #$id');
|
||||
}
|
||||
|
||||
Widget _emptyPage(BuildContext context, String title, String subtitle) => Scaffold(
|
||||
appBar: AppBar(title: Text(title)),
|
||||
body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.description, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
|
||||
])),
|
||||
);
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
|
||||
|
||||
if (analysis == null) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('报告详情')),
|
||||
body: const Center(child: Text('暂无分析数据')),
|
||||
);
|
||||
}
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('报告解读'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () {
|
||||
ref.read(reportProvider.notifier).clearAnalysis();
|
||||
popRoute(ref);
|
||||
},
|
||||
),
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
_buildReportHeader(analysis),
|
||||
const SizedBox(height: 20),
|
||||
_buildAnalysisSection(analysis),
|
||||
const SizedBox(height: 20),
|
||||
_buildSummarySection(analysis),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportHeader(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('📋', style: TextStyle(fontSize: 24)),
|
||||
const SizedBox(width: 12),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
|
||||
const SizedBox(height: 4),
|
||||
const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
]),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAnalysisSection(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(children: [
|
||||
const Text('🧪', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIndicatorRow(Indicator ind) {
|
||||
Color statusColor;
|
||||
IconData statusIcon;
|
||||
switch (ind.status) {
|
||||
case 'high':
|
||||
statusColor = const Color(0xFFE53935);
|
||||
statusIcon = Icons.arrow_upward;
|
||||
break;
|
||||
case 'low':
|
||||
statusColor = const Color(0xFFF9A825);
|
||||
statusIcon = Icons.arrow_downward;
|
||||
break;
|
||||
default:
|
||||
statusColor = const Color(0xFF43A047);
|
||||
statusIcon = Icons.check_circle;
|
||||
}
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(ind.name, style: const TextStyle(fontSize: 14)),
|
||||
if (ind.referenceRange != null)
|
||||
Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 12, color: Colors.grey[400])),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
Column(children: [
|
||||
Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: statusColor)),
|
||||
Icon(statusIcon, size: 16, color: statusColor),
|
||||
]),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSummarySection(ReportAnalysis analysis) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF3C7),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('💡', style: TextStyle(fontSize: 20)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFD97706))),
|
||||
]),
|
||||
const SizedBox(height: 12),
|
||||
Text(analysis.summary, style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user