Files
AI-Health/health_app/lib/pages/home/home_page.dart
MingNian 58af5f6d5b feat: UI 清新风全面改造 — 朝露主题 + shadcn_ui + 全局字号放大
- 全新"朝露"主题:深青绿主色(#2D6A4F),暖白底,完整设计Token
- 引入 shadcn_ui 组件库,MaterialApp 注入 ShadTheme
- 新增 4 个公共组件:AppCard、AppMenuItem、AppEmptyState、AppTabChip
- 全面消除硬编码颜色,统一使用 AppTheme Token
- 全局字号 +3~4pt,图标等比放大 +3px
- home/medication/profile/settings/login 等核心页面重写
- 路由和功能逻辑零改动
2026-06-08 18:06:18 +08:00

321 lines
13 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_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();
String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {};
final _focusNode = FocusNode();
@override void initState() { super.initState(); }
@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();
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;
final selectedAgent = ref.watch(selectedAgentProvider);
final theme = ShadTheme.of(context);
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(); }
});
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: theme.colorScheme.background,
body: SafeArea(
child: Column(children: [
_buildHeader(user, theme),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildBottomBar(context, selectedAgent, theme),
]),
),
);
}
// ═══════ 顶部栏 ═══════
Widget _buildHeader(dynamic user, ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 0.5)),
),
child: Row(children: [
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar(
radius: 20,
backgroundColor: AppTheme.primaryLight,
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
child: user?.avatarUrl == null ? const Icon(Icons.person, size: 28, color: AppTheme.primary) : null,
),
)),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Icon(LucideIcons.bot, size: 19, color: AppTheme.primary),
const SizedBox(width: 4),
Text('AI 健康管家', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
]),
const SizedBox(height: 2),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)),
])),
Icon(LucideIcons.bell, size: 25, color: theme.colorScheme.mutedForeground),
]),
);
}
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.heart),
(ActiveAgent.diet, '拍饮食', LucideIcons.utensils),
(ActiveAgent.medication, '药管家', LucideIcons.pill),
(ActiveAgent.report, '看报告', LucideIcons.fileText),
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
];
Widget _buildAgentBar(ActiveAgent? selected, ShadThemeData theme) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sMd),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
final isActive = selected == agent;
return GestureDetector(
onTap: () {
final notifier = ref.read(selectedAgentProvider.notifier);
if (isActive) {
notifier.select(null);
} else {
notifier.select(agent);
ref.read(chatProvider.notifier).setAgent(agent);
if (_welcomedAgents.add(agent)) {
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
}
}
},
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: isActive ? AppTheme.primary : theme.colorScheme.card,
borderRadius: BorderRadius.circular(AppTheme.rPill),
boxShadow: isActive ? [AppTheme.shadowLight] : null,
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: isActive ? Colors.white : theme.colorScheme.mutedForeground),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 15, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : theme.colorScheme.mutedForeground)),
]),
),
);
},
),
);
}
// ═══════ 底部区域 ═══════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent, ShadThemeData theme) {
return Column(mainAxisSize: MainAxisSize.min, children: [
Container(
padding: const EdgeInsets.only(top: AppTheme.sSm, bottom: 6),
child: _buildAgentBar(selectedAgent, theme),
),
if (_pickedImagePath != null) _buildImagePreview(theme),
_buildCompactInputBar(theme),
]);
}
Widget _buildImagePreview(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(children: [
Stack(children: [
ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.rXs),
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
),
Positioned(top: -4, right: -4, child: GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: Container(
width: 20, height: 20,
decoration: const BoxDecoration(color: AppTheme.text, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 17, color: Colors.white),
),
)),
]),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 15, color: theme.colorScheme.mutedForeground)),
]),
);
}
Widget _buildCompactInputBar(ShadThemeData theme) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sSm, vertical: AppTheme.sSm),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
ShadButton.ghost(
size: ShadButtonSize.sm,
leading: Icon(LucideIcons.paperclip, size: 25, color: theme.colorScheme.mutedForeground),
onPressed: () => _showAttachmentPicker(context, theme),
),
Expanded(
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: TextStyle(fontSize: 18, color: theme.colorScheme.foreground),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: InputDecoration(
hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground),
filled: true,
fillColor: theme.colorScheme.muted,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
),
onSubmitted: (_) => _sendMessage(),
),
),
const SizedBox(width: 4),
ShadButton(
size: ShadButtonSize.sm,
leading: Icon(LucideIcons.send, size: 21, color: theme.colorScheme.primaryForeground),
onPressed: _sendMessage,
),
]),
);
}
void _showAttachmentPicker(BuildContext context, ShadThemeData theme) {
showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.card,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.rXl)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.sMd),
child: Wrap(children: [
ListTile(
leading: Icon(LucideIcons.camera, color: theme.colorScheme.foreground),
title: Text('拍照', style: TextStyle(color: theme.colorScheme.foreground)),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: Icon(LucideIcons.image, color: theme.colorScheme.foreground),
title: Text('从相册选', style: TextStyle(color: theme.colorScheme.foreground)),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
),
ListTile(
leading: Icon(LucideIcons.file, color: theme.colorScheme.foreground),
title: Text('传文件', style: TextStyle(color: theme.colorScheme.foreground)),
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 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;
setState(() => _pickedImagePath = picked.path);
}
}
Future<void> _pickFoodImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.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');
}
}
}