Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian c70d5d4be6 fix: 管理员系统 + 注册流程重构 + UI改版 + 全链路修复
- 新增管理员角色(手机号12345678910),管理员可增删医生、查看患者
- 注册页重构:去掉角色选择+审核码,改为选医生+填姓名
- 医生端点按DoctorId过滤患者,Patient↔Doctor关系建立
- Doctor/DoctorProfile/User实体新增关联字段
- JSON循环引用修复(IgnoreCycles)
- /api/doctors改为公开接口
- 登录闪屏修复+原生Android启动页
- 输入框全局白底+灰框
- 蓝牙重连同步修复
- Web端(doctor_web+health_app/web)全部删除
- 全局UI改版:白底企业风,新配色和组件系统
- 新品牌图标和启动图
2026-06-16 15:16:44 +08:00

478 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart';
import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart';
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
final _focusNode = FocusNode();
String? _pickedImagePath;
int _lastMsgCount = 0;
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
void _sendMessage() {
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
_focusNode.unfocus();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
}
@override
Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
final user = auth.user;
ref.listen(cameraActionProvider, (prev, next) {
if (next == 'camera') {
_pickImage(ImageSource.camera);
ref.read(cameraActionProvider.notifier).clear();
} else if (next == 'gallery') {
_pickImage(ImageSource.gallery);
ref.read(cameraActionProvider.notifier).clear();
}
});
ref.listen(dietActionProvider, (prev, next) {
if (next == 'pickFoodCamera') {
_pickFoodImage(ImageSource.camera);
ref.read(dietActionProvider.notifier).clear();
} else if (next == 'pickFoodGallery') {
_pickFoodImage(ImageSource.gallery);
ref.read(dietActionProvider.notifier).clear();
}
});
final currentCount = chatState.messages.length;
if (currentCount > _lastMsgCount) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) {
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
}
});
}
_lastMsgCount = currentCount;
return Scaffold(
drawer: const HealthDrawer(),
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Column(
children: [
_buildHeader(user),
Expanded(
child: ChatMessagesView(
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
),
_buildBottomBar(context),
],
),
),
),
);
}
Widget _buildHeader(dynamic user) {
final name = (user?.name != null && user!.name!.isNotEmpty)
? user.name
: '用户';
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 8),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
border: const Border(bottom: BorderSide(color: AppColors.divider)),
),
child: Row(
children: [
Builder(
builder: (ctx) {
return _HeaderIconButton(
icon: LucideIcons.menu,
onTap: () => Scaffold.of(ctx).openDrawer(),
);
},
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'AI 健康管家',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Text(
'${_getGreeting()}$name',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
],
),
),
_HeaderIconButton(
icon: LucideIcons.bell,
onTap: () => pushRoute(ref, 'notificationPrefs'),
),
],
),
);
}
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
(ActiveAgent.health, '记数据', LucideIcons.heartPulse),
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
(ActiveAgent.medication, '药管家', LucideIcons.pill),
(ActiveAgent.report, '报告分析', LucideIcons.fileText),
(ActiveAgent.exercise, '运动', LucideIcons.activity),
];
Widget _buildAgentBar() {
return SizedBox(
height: 42,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 14),
itemCount: _agentDefs.length,
separatorBuilder: (_, _) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
return GestureDetector(
onTap: () =>
ref.read(chatProvider.notifier).triggerAgent(agent, label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: AppColors.primary),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
);
},
),
);
}
Widget _buildBottomBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.76),
border: const Border(top: BorderSide(color: AppColors.divider)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
_buildAgentBar(),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
),
);
}
Widget _buildImagePreview() {
return Container(
margin: const EdgeInsets.fromLTRB(14, 8, 14, 0),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Image.file(
File(_pickedImagePath!),
width: 48,
height: 48,
fit: BoxFit.cover,
),
),
const SizedBox(width: 10),
const Expanded(
child: Text(
'点击发送图片',
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
),
GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
),
],
),
);
}
Widget _buildInputBar() {
return Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
_RoundToolButton(
icon: LucideIcons.plus,
onTap: () => _showAttachmentPicker(context),
),
const SizedBox(width: 8),
Expanded(
child: ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 118),
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: const TextStyle(
fontSize: 15,
color: AppColors.textPrimary,
),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: '输入你想说的...',
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 11,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.primary),
),
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendMessage,
child: Container(
width: 42,
height: 42,
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 2),
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(21),
boxShadow: AppColors.buttonShadow,
),
child: const Icon(
LucideIcons.send,
size: 19,
color: Colors.white,
),
),
),
],
),
);
}
void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Wrap(
children: [
ListTile(
leading: const Icon(
Icons.camera_alt_outlined,
color: AppColors.primary,
),
title: const Text('拍照'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.camera);
},
),
ListTile(
leading: const Icon(
Icons.photo_library_outlined,
color: AppColors.primary,
),
title: const Text('从相册选择'),
onTap: () {
Navigator.pop(ctx);
_pickImage(ImageSource.gallery);
},
),
ListTile(
leading: const Icon(
Icons.file_open_outlined,
color: AppColors.primary,
),
title: const Text('传文件'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
if (result != null && result.files.isNotEmpty) {
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
if (mounted) setState(() {});
}
},
),
],
),
),
),
);
}
Future<void> _pickImage(ImageSource source) async {
final picked = await ImagePicker().pickImage(
source: source,
imageQuality: 85,
);
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
}
}
Future<void> _pickFoodImage(ImageSource source) async {
final picked = await ImagePicker().pickImage(
source: source,
imageQuality: 80,
maxWidth: 1024,
maxHeight: 1024,
);
if (picked != null && mounted) {
ref.read(dietProvider.notifier).reset();
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
pushRoute(ref, 'dietCapture');
}
}
}
class _HeaderIconButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _HeaderIconButton({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
),
child: Icon(icon, size: 20, color: AppColors.textPrimary),
),
);
}
}
class _RoundToolButton extends StatelessWidget {
final IconData icon;
final VoidCallback onTap;
const _RoundToolButton({required this.icon, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 42,
height: 42,
margin: const EdgeInsets.only(bottom: 2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(21),
border: Border.all(color: AppColors.border),
),
child: Icon(icon, size: 21, color: AppColors.textSecondary),
),
);
}
}