feat: UI清爽紫蓝风全面改造 + 对话卡片重构 + 配色体系升级
- 新增 AppColors 统一配色方案(清爽紫蓝,参考蚂蚁阿福风格) - 新增 common_widgets(GradientBorderButton / CardActionButton / IconBox) - AgentWelcomeCard 美化:渐变 header + AI标签 + 核心功能区 + 快捷操作区 - DataConfirmCard 统一:三合一(健康/药品/运动),可编辑字段列表 - 删除死代码:medicationConfirm/dietAnalysis/reportAnalysis/quickOptions 卡片 - 删除死路由:doctors/profileEdit/editProfile - AI 对话去除 Markdown 符号(_stripMd),统一行间距 1.5 - 逐字淡入动画修复:key 改用 stableId 避免重复触发 - 健康概览时间修复:前端发送 UTC 避免时区歧义 - PG 数据目录迁入项目 backend/pgdata/,加入 .gitignore - 新增欧姆龙血压计实施方案文档
This commit is contained in:
@@ -137,7 +137,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
: _selected == 'spo2' ? 'SpO2'
|
||||
: 'Weight',
|
||||
'source': 'Manual',
|
||||
'recordedAt': DateTime.now().toIso8601String(),
|
||||
'recordedAt': DateTime.now().toUtc().toIso8601String(),
|
||||
'unit': _unit,
|
||||
};
|
||||
if (_isBP) {
|
||||
|
||||
@@ -7,108 +7,6 @@ import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../providers/consultation_provider.dart';
|
||||
|
||||
/// 医生列表页
|
||||
class DoctorListPage extends ConsumerWidget {
|
||||
const DoctorListPage({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final doctors = ref.watch(doctorListProvider);
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('选择医生')),
|
||||
body: doctors.when(
|
||||
data: (list) {
|
||||
if (list.isEmpty) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.person_search, size: 64, color: Colors.grey[300]),
|
||||
const SizedBox(height: 12),
|
||||
Text('暂无可用医生', style: Theme.of(context).textTheme.bodyMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
return SizedBox(
|
||||
height: 330,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
itemCount: list.length,
|
||||
itemBuilder: (ctx, i) {
|
||||
final d = list[i];
|
||||
return Container(
|
||||
width: 155,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [
|
||||
BoxShadow(color: AppTheme.primary.withAlpha(15), blurRadius: 12, offset: const Offset(0, 4))
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
child: Text(
|
||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||||
style: const TextStyle(fontSize: 25, fontWeight: FontWeight.w600, color: AppTheme.primaryLight),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(d['title'] ?? '', style: const TextStyle(fontSize: 15, color: AppTheme.primaryLight, fontWeight: FontWeight.w500)),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
const SizedBox(height: 8),
|
||||
Text(d['introduction'] ?? '',
|
||||
style: const TextStyle(fontSize: 15, color: Color(0xFF888888), height: 1.4),
|
||||
maxLines: 4,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center),
|
||||
const SizedBox(height: 14),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 34,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: AppTheme.primary,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
padding: EdgeInsets.zero,
|
||||
),
|
||||
child: const Text('立即咨询', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => Center(
|
||||
child: Text('加载失败', style: Theme.of(context).textTheme.bodyMedium),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 问诊对话页 — 完整的 AI 分身聊天界面
|
||||
class DoctorChatPage extends ConsumerStatefulWidget {
|
||||
final String id;
|
||||
|
||||
@@ -33,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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);
|
||||
@@ -132,32 +133,22 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
// 用户标签 → 欢迎卡片,不发 AI 后端
|
||||
ref.read(chatProvider.notifier).triggerAgent(agent, label);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? AppTheme.primary : theme.colorScheme.card,
|
||||
color: theme.colorScheme.card,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rPill),
|
||||
boxShadow: isActive ? [AppTheme.shadowLight] : null,
|
||||
border: Border.all(color: theme.colorScheme.border),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 17, color: isActive ? Colors.white : theme.colorScheme.mutedForeground),
|
||||
Icon(icon, size: 17, color: 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)),
|
||||
Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../services/health_service.dart';
|
||||
|
||||
class ProfileDetailPage extends ConsumerWidget {
|
||||
const ProfileDetailPage({super.key});
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
final userService = ref.watch(userServiceProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)),
|
||||
title: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.person_outline, size: 23, color: Colors.grey[600]),
|
||||
const SizedBox(width: 6),
|
||||
Text('健康档案', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(userService), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: AppTheme.primary, side: const BorderSide(color: AppTheme.primary), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserCard(UserService userService) {
|
||||
return FutureBuilder<Map<String, dynamic>?>(
|
||||
future: userService.getProfile(),
|
||||
builder: (ctx, snap) {
|
||||
final p = snap.data;
|
||||
final name = (p != null && p['name'] != null && (p['name'] as String).isNotEmpty) ? p['name'] : '用户';
|
||||
final gender = p?['gender'] ?? '';
|
||||
final birth = p?['birthDate'] ?? '';
|
||||
final ageStr = birth.toString().isNotEmpty ? _calcAge(birth.toString()) : '';
|
||||
final info = [if (ageStr.isNotEmpty) ageStr, if (gender.toString().isNotEmpty) gender].join(' · ');
|
||||
return Container(
|
||||
width: double.infinity, padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Row(children: [
|
||||
CircleAvatar(radius: 32, backgroundColor: AppTheme.primaryLight,
|
||||
child: Text(name.toString().isNotEmpty ? name.toString()[0] : '?', style: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold, color: AppTheme.primary))),
|
||||
const SizedBox(width: 16),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(name.toString(), style: const TextStyle(fontSize: 25, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
if (info.isNotEmpty) const SizedBox(height: 4),
|
||||
if (info.isNotEmpty) Text(info, style: TextStyle(fontSize: 17, color: Colors.grey[500])),
|
||||
])),
|
||||
Icon(Icons.chevron_right, size: 28, color: Colors.grey[400]),
|
||||
]),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _calcAge(String birthDate) {
|
||||
final d = DateTime.tryParse(birthDate);
|
||||
if (d == null) return '';
|
||||
final now = DateTime.now();
|
||||
var age = now.year - d.year;
|
||||
if (now.month < d.month || (now.month == d.month && now.day < d.day)) age--;
|
||||
return '$age岁';
|
||||
}
|
||||
|
||||
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('健康概览', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text('(最近测量)', style: TextStyle(fontSize: 16, color: Colors.grey[500])),
|
||||
const SizedBox(height: 16),
|
||||
healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary))), error: (_, e) => _buildMetricsEmpty()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMetricsList(Map<String, dynamic> data) {
|
||||
return Column(children: [_metricRow(Icons.favorite, '血压', _formatBP(data['BloodPressure'])), const Divider(), _metricRow(Icons.monitor_heart, '心率', _formatMetric(data['HeartRate'], '次/分')), const Divider(), _metricRow(Icons.bloodtype, '血糖', _formatMetric(data['Glucose'], 'mmol/L')), const Divider(), _metricRow(Icons.air, '血氧', _formatMetric(data['SpO2'], '%')), const Divider(), _metricRow(Icons.monitor_weight, '体重', _formatMetric(data['Weight'], 'kg'))]);
|
||||
}
|
||||
|
||||
Widget _buildMetricsEmpty() {
|
||||
return Column(children: [_metricRow(Icons.favorite, '血压', '--/--'), const Divider(), _metricRow(Icons.monitor_heart, '心率', '-- 次/分'), const Divider(), _metricRow(Icons.bloodtype, '血糖', '-- mmol/L'), const Divider(), _metricRow(Icons.air, '血氧', '-- %'), const Divider(), _metricRow(Icons.monitor_weight, '体重', '-- kg')]);
|
||||
}
|
||||
|
||||
String _formatBP(dynamic bp) { if (bp is Map) { final s = bp['systolic']; final d = bp['diastolic']; if (s != null && d != null) return '$s/$d'; } return '--/--'; }
|
||||
String _formatMetric(dynamic val, String unit) { if (val is Map) { final v = val['value']; if (v != null) return '$v$unit'; } return '-- $unit'; }
|
||||
|
||||
Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 21, color: AppTheme.primary)), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 18, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 21, color: Colors.grey[400])])));
|
||||
|
||||
Widget _buildHistoryList() {
|
||||
final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}];
|
||||
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 16, color: AppTheme.primary))])), ...items.map(_historyItem)]));
|
||||
}
|
||||
|
||||
Widget _historyItem(Map<String, dynamic> item) {
|
||||
final isNormal = item['status'] == 'normal';
|
||||
return Container(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(item['date']?.toString() ?? '', style: const TextStyle(fontSize: 17, color: Color(0xFF9E9E9E))), const SizedBox(width: 8), Expanded(child: Text(item['label']?.toString() ?? '', style: const TextStyle(fontSize: 17, color: Color(0xFF333333)))), Text(item['value']?.toString() ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: isNormal ? AppTheme.success.withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 14, color: isNormal ? AppTheme.success : const Color(0xFFFF9800))))]));
|
||||
}
|
||||
}
|
||||
@@ -521,13 +521,6 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
);
|
||||
}
|
||||
|
||||
/// 编辑资料(已合并到健康档案,保留路由兼容)
|
||||
class EditProfilePage extends ConsumerWidget {
|
||||
final String? id;
|
||||
const EditProfilePage({super.key, this.id});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) => const HealthArchivePage();
|
||||
}
|
||||
|
||||
/// 健康日历
|
||||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||||
const HealthCalendarPage({super.key});
|
||||
|
||||
Reference in New Issue
Block a user