Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 4507083f3f refactor: 重构蓝牙设备页与新增设备页的扫描-连接-录入流程
- 通用 BLE 健康设备识别(血压计/血糖仪/体重秤/血氧仪 标准 service UUID)
- 蓝牙设备页静默自动同步:仅匹配已绑定的设备名+类型,收到数据后变绿
- 新增设备页:扫描所有支持类型设备 → 选连接 → 绑定 + 同步首次数据
- 修复 read 缓存导致的重复录入循环(删除主动 read,依赖 indicate 推送)
- 修复 isConnected 缓存状态导致连接异常(强制先 disconnect 再 connect)
- 录入弹窗:血压心率同等级展示、3 秒自动关闭/手动确认
- 顺手更新本机开发 IP 与清理过时设计文档
2026-06-28 22:53:35 +08:00

604 lines
19 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 'dart:async';
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/app_theme.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;
Timer? _notificationTimer;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => ref.invalidate(notificationUnreadCountProvider),
);
_notificationTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => ref.invalidate(notificationUnreadCountProvider),
);
}
@override
void dispose() {
_notificationTimer?.cancel();
_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(
backgroundColor: const Color(0xFFFFFCFF),
drawer: const HealthDrawer(),
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
body: AppBackground(
safeArea: true,
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
: '用户';
final unreadCount = ref.watch(notificationUnreadCountProvider).value ?? 0;
return Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.34),
border: Border(
bottom: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.025),
blurRadius: 16,
offset: const Offset(0, 6),
),
],
),
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(
'小脉健康',
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,
badgeCount: unreadCount,
onTap: () => pushRoute(ref, 'notifications'),
),
],
),
);
}
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, 'AI问诊', 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];
final gradient = _agentGradient(agent);
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.withValues(alpha: 0.86),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: Colors.white, width: 1.2),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
blurRadius: 14,
offset: const Offset(0, 7),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 16,
height: 16,
decoration: BoxDecoration(
gradient: gradient,
borderRadius: BorderRadius.circular(6),
),
child: Icon(icon, size: 11, color: Colors.white),
),
const SizedBox(width: 6),
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
),
);
},
),
);
}
LinearGradient _agentGradient(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFFFCAD4), Color(0xFFFF9AAE)],
),
ActiveAgent.health => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFA1FFCE), Color(0xFF69DB8F)],
),
ActiveAgent.diet => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFF6D365), Color(0xFFFDA085)],
),
ActiveAgent.medication => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFF89F7FE), Color(0xFF66A6FF)],
),
ActiveAgent.report => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
),
ActiveAgent.exercise => const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [Color(0xFFB6F2FF), Color(0xFF7DD3FC)],
),
_ => AppColors.primaryGradient,
};
}
Widget _buildBottomBar(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.28),
border: Border(
top: BorderSide(color: Colors.white.withValues(alpha: 0.62)),
),
boxShadow: [
BoxShadow(
color: const Color(0xFF101828).withValues(alpha: 0.045),
blurRadius: 20,
offset: const Offset(0, -8),
),
],
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 6),
_buildAgentBar(),
const SizedBox(height: 6),
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: Colors.white.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
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, 0, 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.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(22),
borderSide: const BorderSide(
color: AppColors.auraIndigo,
width: 1.2,
),
),
),
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;
final int badgeCount;
const _HeaderIconButton({
required this.icon,
required this.onTap,
this.badgeCount = 0,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: 20, color: AppColors.textPrimary),
),
if (badgeCount > 0)
Positioned(
right: -5,
top: -5,
child: Container(
constraints: const BoxConstraints(minWidth: 18, minHeight: 18),
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: Colors.white, width: 2),
),
alignment: Alignment.center,
child: Text(
badgeCount > 99 ? '99+' : '$badgeCount',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
);
}
}
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(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(21),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: 21, color: AppColors.primaryDark),
),
);
}
}