feat: UI全面改造 + 后端多项修复

【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
This commit is contained in:
MingNian
2026-06-10 18:27:38 +08:00
parent 3964cf2bcb
commit 63d2092c24
46 changed files with 2003 additions and 1431 deletions

View File

@@ -4,6 +4,7 @@ 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';
@@ -24,6 +25,7 @@ class _HomePageState extends ConsumerState<HomePage> {
String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {};
final _focusNode = FocusNode();
int _lastMsgCount = 0;
@override void initState() { super.initState(); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); }
@@ -33,7 +35,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
_focusNode.unfocus(); // 收起键盘
_focusNode.unfocus();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
@@ -46,8 +48,6 @@ class _HomePageState extends ConsumerState<HomePage> {
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(); }
@@ -58,49 +58,52 @@ class _HomePageState extends ConsumerState<HomePage> {
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(0);
});
}
_lastMsgCount = currentCount;
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: theme.colorScheme.background,
body: SafeArea(
body: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Column(children: [
_buildHeader(user, theme),
_buildHeader(user),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildBottomBar(context, selectedAgent, theme),
_buildBottomBar(context),
]),
),
),
);
}
// ═══════ 顶部栏 ═══════
Widget _buildHeader(dynamic user, ShadThemeData theme) {
// ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════
Widget _buildHeader(dynamic user) {
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)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
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,
child: Container(
width: 38, height: 38,
decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)),
child: const Icon(LucideIcons.menu, size: 20, color: AppColors.primary),
),
)),
const SizedBox(width: 10),
const SizedBox(width: 12),
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),
const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
])),
Icon(LucideIcons.bell, size: 25, color: theme.colorScheme.mutedForeground),
GestureDetector(
onTap: () => pushRoute(ref, 'notificationPrefs'),
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary),
),
]),
);
}
@@ -113,7 +116,7 @@ class _HomePageState extends ConsumerState<HomePage> {
return '晚上好';
}
// ═══════ 智能体选择条 ═══════
// ═══════ 智能体胶囊 ═══════
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
(ActiveAgent.health, '记数据', LucideIcons.heart),
@@ -123,32 +126,29 @@ class _HomePageState extends ConsumerState<HomePage> {
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
];
Widget _buildAgentBar(ActiveAgent? selected, ShadThemeData theme) {
return Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sMd),
Widget _buildAgentBar() {
return SizedBox(
height: 32,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
separatorBuilder: (_, i) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
return GestureDetector(
onTap: () {
// 用户标签 → 欢迎卡片,不发 AI 后端
ref.read(chatProvider.notifier).triggerAgent(agent, label);
},
onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: theme.colorScheme.card,
borderRadius: BorderRadius.circular(AppTheme.rPill),
border: Border.all(color: theme.colorScheme.border),
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: AppColors.cardShadowLight,
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: theme.colorScheme.mutedForeground),
Icon(icon, size: 15, color: AppColors.primary),
const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primary)),
]),
),
);
@@ -157,122 +157,114 @@ class _HomePageState extends ConsumerState<HomePage> {
);
}
// ═══════ 底部区 ═══════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent, ShadThemeData theme) {
// ═══════ 底部输入区 ═══════
Widget _buildBottomBar(BuildContext context) {
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),
Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
]);
}
Widget _buildImagePreview(ShadThemeData theme) {
Widget _buildImagePreview() {
return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
color: AppColors.cardInner,
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),
),
)),
]),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover),
),
const SizedBox(width: 10),
const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 15, color: theme.colorScheme.mutedForeground)),
GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
),
]),
);
}
Widget _buildCompactInputBar(ShadThemeData theme) {
Widget _buildInputBar() {
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)),
),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
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(),
GestureDetector(
onTap: () => _showAttachmentPicker(context),
child: const Padding(
padding: EdgeInsets.only(bottom: 10),
child: Icon(LucideIcons.paperclip, size: 24, color: AppColors.textSecondary),
),
),
const SizedBox(width: 4),
ShadButton(
size: ShadButtonSize.sm,
leading: Icon(LucideIcons.send, size: 21, color: theme.colorScheme.primaryForeground),
onPressed: _sendMessage,
const SizedBox(width: 8),
Expanded(
child: Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: TextField(
controller: _textCtrl,
focusNode: _focusNode,
style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
maxLines: null,
textInputAction: TextInputAction.newline,
decoration: const InputDecoration(
hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true, fillColor: Colors.white,
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
),
onSubmitted: (_) => _sendMessage(),
),
),
),
const SizedBox(width: 8),
GestureDetector(
onTap: _sendMessage,
child: Container(
width: 40, height: 40,
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(LucideIcons.send, size: 20, color: Colors.white),
),
),
]),
);
}
void _showAttachmentPicker(BuildContext context, ShadThemeData theme) {
void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: theme.colorScheme.card,
backgroundColor: Colors.white,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.rXl)),
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
builder: (ctx) => SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.sMd),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(children: [
ListTile(
leading: Icon(LucideIcons.camera, color: theme.colorScheme.foreground),
title: Text('拍照', style: TextStyle(color: theme.colorScheme.foreground)),
leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
),
ListTile(
leading: Icon(LucideIcons.image, color: theme.colorScheme.foreground),
title: Text('从相册选', style: TextStyle(color: theme.colorScheme.foreground)),
leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
title: const Text('从相册选'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
),
ListTile(
leading: Icon(LucideIcons.file, color: theme.colorScheme.foreground),
title: Text('传文件', style: TextStyle(color: theme.colorScheme.foreground)),
leading: const Icon(Icons.file_open_outlined, color: AppColors.primary),
title: const Text('传文件'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles();
@@ -289,8 +281,7 @@ class _HomePageState extends ConsumerState<HomePage> {
}
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85);
final picked = await ImagePicker().pickImage(source: source, imageQuality: 85);
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
@@ -299,8 +290,7 @@ class _HomePageState extends ConsumerState<HomePage> {
}
Future<void> _pickFoodImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
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);