fix: 修复 Flutter 前端多项功能 + 后端运动计划 API
- Android 添加相机/存储权限,拍照和相册功能可用 - AI 回复支持 Markdown 渲染(加粗/表格不再显示**乱码) - 附件按钮接线,支持拍照/相册/文件选择 - 智能体面板按钮全部接线(拍照/上传/手动录入/导航) - 侧边栏 AI 录入后自动刷新健康数据 - 运动计划页增加创建按钮 + 打卡功能 - 后端运动计划支持 AI 创建和打卡(Tool Calling) - 修复 CreateExercisePlanRequest JSON 反序列化
This commit is contained in:
@@ -10,11 +10,11 @@ class HealthApp extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return const MaterialApp(
|
||||
return MaterialApp(
|
||||
title: '健康管家',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.lightTheme,
|
||||
home: _RootNavigator(),
|
||||
home: const _RootNavigator(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
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/api_client.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../widgets/agent_bar.dart';
|
||||
import '../../widgets/health_drawer.dart';
|
||||
@@ -137,7 +142,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {},
|
||||
onPressed: () => _onAgentAction(label),
|
||||
icon: Icon(icon, size: 20),
|
||||
label: Text(label),
|
||||
style: OutlinedButton.styleFrom(
|
||||
@@ -151,6 +156,83 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
void _onAgentAction(String label) {
|
||||
switch (label) {
|
||||
case '拍照':
|
||||
_pickImage(ImageSource.camera);
|
||||
break;
|
||||
case '上传照片':
|
||||
_pickImage(ImageSource.gallery);
|
||||
break;
|
||||
case '手动录入血压':
|
||||
_textCtrl.text = '血压 ';
|
||||
break;
|
||||
case '手动录入血糖':
|
||||
_textCtrl.text = '血糖 ';
|
||||
break;
|
||||
case '手动录入心率':
|
||||
_textCtrl.text = '心率 ';
|
||||
break;
|
||||
case '用药管理':
|
||||
pushRoute(ref, 'medications');
|
||||
break;
|
||||
case '找医生':
|
||||
pushRoute(ref, 'doctors');
|
||||
break;
|
||||
case '查看本周计划':
|
||||
pushRoute(ref, 'exercisePlan');
|
||||
break;
|
||||
case '创建新计划':
|
||||
pushRoute(ref, 'exercisePlan');
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
_textCtrl.text = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}';
|
||||
setState(() {});
|
||||
}
|
||||
}
|
||||
|
||||
void _showAttachmentPicker(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt),
|
||||
title: const Text('拍照'),
|
||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library),
|
||||
title: const Text('从相册选'),
|
||||
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.attach_file),
|
||||
title: const Text('传文件'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||||
setState(() {});
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputBar() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
@@ -159,7 +241,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
||||
),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}),
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _textCtrl,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../../providers/chat_provider.dart';
|
||||
|
||||
@@ -56,10 +57,17 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
children: [
|
||||
if (!isUser && chatState.isStreaming && msg.content.isEmpty)
|
||||
_buildThinkingIndicator()
|
||||
else if (isUser)
|
||||
Text(msg.content, style: const TextStyle(fontSize: 16, color: Colors.white))
|
||||
else
|
||||
Text(
|
||||
msg.content.isEmpty && !isUser ? '...' : msg.content,
|
||||
style: TextStyle(fontSize: 16, color: isUser ? Colors.white : const Color(0xFF1A1A1A)),
|
||||
MarkdownBody(
|
||||
data: msg.content.isEmpty ? '...' : 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]),
|
||||
),
|
||||
),
|
||||
if (!isUser && msg.content.isNotEmpty && !chatState.isStreaming)
|
||||
Padding(
|
||||
|
||||
@@ -46,9 +46,14 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
final plan = ref.watch(currentExercisePlanProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('运动计划')),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () => _createDefaultPlan(ref),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建本周计划'),
|
||||
),
|
||||
body: plan.when(
|
||||
data: (data) {
|
||||
if (data == null) return _empty(context, '运动计划', '暂无运动计划');
|
||||
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
|
||||
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
return ListView.builder(
|
||||
@@ -61,16 +66,39 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
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 ? const Text('✅ 已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))) : const Text('待完成', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
trailing: isDone ? null : IconButton(icon: const Icon(Icons.check, color: Color(0xFF43A047)), onPressed: () { _checkIn(ref, item['id']); }),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => _empty(context, '运动计划', '暂无运动计划'),
|
||||
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
final items = List.generate(7, (i) => {
|
||||
'dayOfWeek': i,
|
||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||||
'isRestDay': i == 2 || i == 5,
|
||||
});
|
||||
await service.createPlan({
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
}
|
||||
|
||||
void _checkIn(WidgetRef ref, String itemId) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
}
|
||||
}
|
||||
|
||||
/// 复查列表
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'dart:async';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
class ChatMessage {
|
||||
@@ -133,6 +134,11 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
_update(aiMsg);
|
||||
case 'notice':
|
||||
state = state.copyWith(noticeText: j['message'] as String?);
|
||||
case 'tool_result':
|
||||
final tool = j['tool'] as String? ?? '';
|
||||
if (tool == 'record_health_data') {
|
||||
refreshHealthData(ref);
|
||||
}
|
||||
case 'status':
|
||||
_done(aiMsg);
|
||||
case 'error':
|
||||
|
||||
@@ -33,6 +33,12 @@ final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
return service.getLatest();
|
||||
});
|
||||
|
||||
/// AI 录入数据后调用,刷新侧边栏
|
||||
void refreshHealthData(WidgetRef ref) {
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.invalidate(medicationListProvider);
|
||||
}
|
||||
|
||||
/// 用药列表 Provider
|
||||
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
|
||||
Reference in New Issue
Block a user