Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian fa2cc994fa feat: 血压计联动心率记录 + 侧边栏左滑手势
- 血压计测量含脉搏时自动同步为 HeartRate 记录
- 首页 Scaffold 增加左侧滑手势打开侧边栏
2026-06-26 16:55:13 +08:00

603 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(
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),
),
);
}
}