Files
AI-Health/health_app/lib/pages/remaining_pages.dart
MingNian 63d2092c24 feat: UI全面改造 + 后端多项修复
【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

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

1081 lines
47 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 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.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/data_providers.dart';
import '../providers/omron_device_provider.dart';
import '../widgets/common_widgets.dart';
/// 饮食记录列表(左滑删除)
class DietRecordListPage extends ConsumerStatefulWidget {
const DietRecordListPage({super.key});
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
}
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Future<List<Map<String, dynamic>>>? _future;
@override void initState() { super.initState(); _refresh(); }
void _refresh() { setState(() { _future = ref.read(dietServiceProvider).getRecords(); }); }
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
final data = snap.data ?? [];
if (data.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入');
return ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: data.length,
itemBuilder: (ctx, i) {
final d = data[i];
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
return SwipeDeleteTile(
key: Key(d['id']?.toString() ?? '$i'),
onDelete: () async {
await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? '');
_refresh();
},
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(mealLabel, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(width: 8),
Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)),
]),
const SizedBox(height: 4),
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis),
])),
Icon(Icons.chevron_right, size: 21, color: AppColors.textHint),
]),
),
);
},
);
},
),
);
}
}
/// 饮食记录详情页
class DietRecordDetailPage extends ConsumerWidget {
final String id;
const DietRecordDetailPage({super.key, required this.id});
@override Widget build(BuildContext context, WidgetRef ref) {
final service = ref.watch(dietServiceProvider);
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getRecords(),
builder: (ctx, snap) {
if (!snap.hasData) return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
final d = snap.data!.firstWhere((r) => r['id']?.toString() == id, orElse: () => <String, dynamic>{});
if (d.isEmpty) return const Center(child: Text('记录不存在'));
final items = (d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final totalCal = d['totalCalories'] ?? 0;
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
return ListView(padding: const EdgeInsets.all(16), children: [
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)),
const SizedBox(width: 14),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
]),
])),
const SizedBox(height: 16),
...items.map((f) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
])),
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
]),
)),
]);
},
),
);
}
}
/// 运动计划列表(含打卡)
class ExercisePlanPage extends ConsumerStatefulWidget {
const ExercisePlanPage({super.key});
@override ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
}
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
Future<List<Map<String, dynamic>>>? _future;
@override void initState() { super.initState(); _load(); }
void _load() => setState(() { _future = ref.read(exerciseServiceProvider).getPlans(); });
Future<void> _checkIn(String id, String itemId) async {
await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
_load();
}
Future<void> _deletePlan(String id) async {
await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider);
_load();
}
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')),
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
final plans = snap.data ?? [];
if (plans.isEmpty) return _empty(context, '运动计划', '暂无计划,点击右下角新建');
return ListView.builder(padding: const EdgeInsets.all(12), itemCount: plans.length, itemBuilder: (ctx, i) {
final p = plans[i];
final items = (p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (items.isEmpty) return const SizedBox.shrink();
final total = items.length;
final done = items.where((it) => it['isCompleted'] == true).length;
final weekStart = p['weekStartDate']?.toString() ?? '';
// 用 DayOfWeek 匹配今天C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
final todayCsDow = DateTime.now().weekday % 7;
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
(it) => it['dayOfWeek'] == todayCsDow,
orElse: () => <String, dynamic>{},
);
final hasTodayItem = todayItem.isNotEmpty;
final todayDone = todayItem['isCompleted'] == true;
final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
return SwipeDeleteTile(
key: Key(p['id']?.toString() ?? '$i'),
onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
onTap: () {},
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
const SizedBox(height: 2),
Text('$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
const SizedBox(height: 6),
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)),
])),
GestureDetector(
onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
child: AnimatedContainer(
duration: const Duration(milliseconds: 300),
width: 40, height: 40,
decoration: BoxDecoration(
color: todayDone ? AppColors.successLight : (hasTodayItem ? AppTheme.primaryLight : AppColors.cardInner),
borderRadius: BorderRadius.circular(12),
),
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint),
),
),
]),
),
);
});
},
),
);
}
}
/// 新建运动计划
class ExercisePlanCreatePage extends ConsumerStatefulWidget {
const ExercisePlanCreatePage({super.key});
@override ConsumerState<ExercisePlanCreatePage> createState() => _ExercisePlanCreatePageState();
}
class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage> {
final _nameCtrl = TextEditingController();
final _durationCtrl = TextEditingController(text: '30');
DateTime _start = DateTime.now();
DateTime _end = DateTime.now().add(const Duration(days: 6));
@override void dispose() { _nameCtrl.dispose(); _durationCtrl.dispose(); super.dispose(); }
Future<void> _save() async {
final name = _nameCtrl.text.trim();
if (name.isEmpty) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('请输入运动名称'))); return; }
final dur = int.tryParse(_durationCtrl.text) ?? 30;
await ref.read(exerciseServiceProvider).createPlanSimple({
'exerciseType': name,
'durationMinutes': dur,
'startDate': '${_start.year}-${_start.month.toString().padLeft(2,'0')}-${_start.day.toString().padLeft(2,'0')}',
'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}',
});
ref.invalidate(currentExercisePlanProvider);
if (mounted) { popRoute(ref); }
}
@override Widget build(BuildContext context) {
return Scaffold(
backgroundColor: AppTheme.bg,
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')),
body: ListView(padding: const EdgeInsets.all(16), children: [
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
const SizedBox(height: 16),
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
const SizedBox(height: 16),
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
const SizedBox(height: 16),
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
const SizedBox(height: 32),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))),
]),
);
}
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6),
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppColors.backgroundSoft, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)),
]);
}
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6),
GestureDetector(
onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); },
child: Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Text('${val.year}-${val.month.toString().padLeft(2,'0')}-${val.day.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 19))),
),
]);
}
}
/// 复查列表(从服务器读取)
class FollowUpListPage extends ConsumerStatefulWidget {
const FollowUpListPage({super.key});
@override ConsumerState<FollowUpListPage> createState() => _FollowUpListPageState();
}
class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
Future<List<Map<String, dynamic>>>? _future;
@override void initState() { super.initState(); _load(); }
void _load() => setState(() { _future = ref.read(followUpServiceProvider).getList(); });
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('复查随访'), centerTitle: true),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppTheme.primaryLight));
}
final list = snap.data ?? [];
if (list.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.event_note_outlined, size: 64, color: AppColors.textHint),
const SizedBox(height: 12),
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4),
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
]),
);
}
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: list.map((item) => _FollowUpItem(item: item)).toList(),
);
},
),
);
}
}
class _FollowUpItem extends StatelessWidget {
final Map<String, dynamic> item;
const _FollowUpItem({required this.item});
String _statusLabel(String? status) {
switch (status) {
case 'Upcoming': return '即将到来';
case 'Completed': return '已完成';
case 'Cancelled': return '已取消';
default: return status ?? '';
}
}
Color _statusColor(String? status) {
switch (status) {
case 'Upcoming': return AppColors.primary;
case 'Completed': return AppTheme.success;
case 'Cancelled': return AppTheme.error;
default: return AppColors.textHint;
}
}
Color _statusBg(String? status) {
switch (status) {
case 'Upcoming': return AppColors.iconBg;
case 'Completed': return AppColors.successLight;
case 'Cancelled': return AppColors.errorLight;
default: return AppColors.backgroundSoft;
}
}
@override
Widget build(BuildContext context) {
final status = item['status']?.toString();
final label = _statusLabel(status);
final color = _statusColor(status);
final bg = _statusBg(status);
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowCard],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)),
),
const Spacer(),
if (item['doctorName'] != null)
Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
]),
const SizedBox(height: 12),
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
if (item['scheduledAt'] != null)
Text(_formatDateTime(item['scheduledAt']?.toString()), style: const TextStyle(fontSize: 17, color: AppColors.textHint)),
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(item['notes']?.toString() ?? '', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
],
]),
);
}
String _formatDateTime(String? iso) {
if (iso == null) return '';
final dt = DateTime.tryParse(iso);
if (dt == null) return iso;
return '${dt.year}-${dt.month.toString().padLeft(2, '0')}-${dt.day.toString().padLeft(2, '0')} ${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
}
/// 健康档案(可编辑)
class HealthArchivePage extends ConsumerStatefulWidget {
const HealthArchivePage({super.key});
@override ConsumerState<HealthArchivePage> createState() => _HealthArchivePageState();
}
class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
final _nameCtrl = TextEditingController();
final _genderCtrl = TextEditingController();
final _birthCtrl = TextEditingController();
final _diagnosisCtrl = TextEditingController();
final _surgeryCtrl = TextEditingController();
final _surgeryDateCtrl = TextEditingController();
final _allergiesCtrl = TextEditingController();
final _chronicCtrl = TextEditingController();
final _dietCtrl = TextEditingController();
final _familyCtrl = TextEditingController();
bool _loading = true;
bool _saving = false;
@override void initState() { super.initState(); _load(); }
@override void dispose() {
_nameCtrl.dispose(); _genderCtrl.dispose(); _birthCtrl.dispose();
_diagnosisCtrl.dispose(); _surgeryCtrl.dispose(); _surgeryDateCtrl.dispose();
_allergiesCtrl.dispose(); _chronicCtrl.dispose(); _dietCtrl.dispose();
_familyCtrl.dispose(); super.dispose();
}
Future<void> _load() async {
final srv = ref.read(userServiceProvider);
try {
final p = await srv.getProfile();
if (p != null && mounted) {
_nameCtrl.text = p['name'] ?? '';
_genderCtrl.text = p['gender'] ?? '';
_birthCtrl.text = p['birthDate'] ?? '';
}
final a = await srv.getHealthArchive();
if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
_surgeryCtrl.text = a['surgeryType'] ?? '';
_surgeryDateCtrl.text = a['surgeryDate'] ?? '';
_allergiesCtrl.text = (a['allergies'] as List?)?.join('') ?? '';
_chronicCtrl.text = (a['chronicDiseases'] as List?)?.join('') ?? '';
_dietCtrl.text = (a['dietRestrictions'] as List?)?.join('') ?? '';
_familyCtrl.text = a['familyHistory'] ?? '';
}
if (mounted) setState(() => _loading = false);
} catch (_) { if (mounted) setState(() => _loading = false); }
}
Future<void> _save() async {
setState(() => _saving = true);
try {
final srv = ref.read(userServiceProvider);
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text,
'surgeryType': _surgeryCtrl.text,
'surgeryDate': _surgeryDateCtrl.text,
'allergies': _allergiesCtrl.text.split('').where((s) => s.isNotEmpty).toList(),
'chronicDiseases': _chronicCtrl.text.split('').where((s) => s.isNotEmpty).toList(),
'dietRestrictions': _dietCtrl.text.split('').where((s) => s.isNotEmpty).toList(),
'familyHistory': _familyCtrl.text,
});
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success),
);
}
} catch (_) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@override Widget build(BuildContext context) {
if (_loading) {
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')),
body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
);
}
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('健康档案'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSection('个人资料', Icons.person_outline, [
_buildField('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')),
const SizedBox(width: 12),
Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')),
]),
]),
const SizedBox(height: 16),
_buildSection('医疗信息', Icons.local_hospital_outlined, [
_buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')),
const SizedBox(width: 12),
Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')),
]),
]),
const SizedBox(height: 16),
_buildSection('病史与限制', Icons.warning_amber_outlined, [
_buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
elevation: 0,
),
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 40),
],
),
);
}
Widget _buildSection(String title, IconData icon, List<Widget> fields) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowCard],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 20, color: AppColors.primary),
),
const SizedBox(width: 10),
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
]),
const SizedBox(height: 16),
...fields,
]),
);
}
Widget _buildField(String label, TextEditingController ctrl, {String? hint}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(fontSize: 17, color: AppColors.textPrimary),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true,
fillColor: AppColors.backgroundSoft,
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rSm),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rSm),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rSm),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
]);
}
}
/// 健康日历
class HealthCalendarPage extends ConsumerStatefulWidget {
const HealthCalendarPage({super.key});
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
}
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
Map<String, List<String>> _events = {};
@override void initState() {
super.initState();
_loadMonth();
}
Future<void> _loadMonth() async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/calendar', queryParameters: {
'year': _currentMonth.year,
'month': _currentMonth.month,
});
final list = (res.data['data'] as List?) ?? [];
final events = <String, List<String>>{};
for (final item in list) {
final map = item as Map<String, dynamic>;
events[map['date'] as String] = List<String>.from(map['events'] ?? []);
}
if (mounted) setState(() => _events = events);
} catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); }
}
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true),
body: Column(children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
const SizedBox(height: 16),
_buildLegend(),
]),
);
}
Widget _buildMonthHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 32),
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)); _loadMonth(); },
),
Text(
'${_currentMonth.year}${_currentMonth.month}',
style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 32),
onPressed: () { setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)); _loadMonth(); },
),
],
);
}
Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', ''];
return Row(children: weekdays.map((day) => Expanded(
child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))),
)).toList());
}
Widget _buildCalendarGrid() {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final daysInMonth = lastDay.day;
final startWeekday = firstDay.weekday % 7;
final days = List.generate(42, (i) {
final dayIndex = i - startWeekday;
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
return dayIndex + 1;
});
return Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: 42,
itemBuilder: (ctx, i) {
final day = days[i];
if (day == null) return const SizedBox();
return _buildDayCell(day);
},
),
);
}
Widget _buildDayCell(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final today = DateTime.now();
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
final events = _getEvents(date);
return Container(
decoration: isToday ? BoxDecoration(
color: AppTheme.primary,
borderRadius: BorderRadius.circular(20),
) : null,
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 19,
color: isToday ? Colors.white : AppColors.textPrimary,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
),
),
if (events.isNotEmpty)
Positioned(
bottom: 4,
child: Row(children: events.map((type) => Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _getEventColor(type),
borderRadius: BorderRadius.circular(3),
),
)).toList()),
),
],
),
);
}
List<String> _getEvents(DateTime date) {
final key = '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
return _events[key] ?? [];
}
Color _getEventColor(String type) {
switch (type) {
case 'medication': return AppTheme.primary;
case 'exercise': return AppTheme.success;
case 'followup': return AppColors.warning;
default: return AppColors.textHint;
}
}
Widget _buildLegend() {
final items = [
{'color': AppTheme.primary, 'label': '用药提醒'},
{'color': AppTheme.success, 'label': '运动计划'},
{'color': AppColors.warning, 'label': '复查随访'},
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 4),
Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
const SizedBox(width: 20),
])).toList()),
);
}
}
/// 静态文本页
class StaticTextPage extends ConsumerWidget {
final String type;
const StaticTextPage({super.key, required this.type});
@override Widget build(BuildContext context, WidgetRef ref) {
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
final contents = {
'privacy': '''## 隐私政策
更新日期2026年1月1日
### 一、信息收集
我们收集以下类型的信息:
- 账户信息:手机号、昵称、头像(您主动提供)
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
- 用药信息:药品名称、剂量、服药时间等用药计划数据
- 饮食记录:通过拍照或手动录入的饮食数据
- 设备信息:设备型号、操作系统版本(用于适配优化)
- 日志信息App 使用情况、崩溃报告
### 二、信息使用
我们使用您的信息用于以下目的:
- 提供和改进健康管理服务
- AI 健康分析和个性化建议
- 用药提醒和复查通知推送
- App 功能优化和问题修复
### 三、信息保护
- 所有健康数据均采用 HTTPS 加密传输
- 数据存储于安全服务器,采用行业标准的加密措施
- 我们不会向任何第三方出售、出租或共享您的个人健康数据
- 医生仅可查看其签约患者的数据,且需经过您的授权
### 四、信息保留
- 对话记录保留 30 天后自动删除
- 您可以随时删除自己的健康数据和对话记录
- 账号注销后,所有数据将在 7 天内永久删除
### 五、您的权利
- 查看和导出您的个人数据
- 修改不准确的个人信息
- 删除不需要的数据
- 注销账号并清除所有数据
- 关闭推送通知
### 六、联系我们
如有任何关于隐私的问题,请联系:
邮箱privacy@healthbutler.com
电话400-xxx-xxxx''',
'about': '''## 关于健康管家
版本v1.0.0 (Build 20260101)
### 产品介绍
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
### 核心功能
- AI 智能问诊:基于大语言模型的健康咨询服务
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
- 智能用药管理AI 解析处方,自动生成用药计划和提醒
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
- 报告智能解读上传检查报告AI 自动提取指标并预解读
- 运动计划管理:制定和追踪每日运动目标
- 在线医生问诊:与签约医生进行远程咨询
### 开发团队
由专业医疗团队与 AI 技术团队联合打造。
### 技术支持
如遇到问题或有建议,请通过以下方式联系我们:
- 在线客服App 内「设置」→「意见反馈」
- 客服热线400-xxx-xxxx工作日 9:00-18:00
### 版权声明
© 2025-2026 健康管家团队。保留所有权利。
本软件受中华人民共和国著作权法保护。''',
};
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: Text(titles[type] ?? '', style: TextStyle(color: AppColors.textPrimary, fontWeight: FontWeight.w600)),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Text(contents[type] ?? '内容加载中...', style: TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary)),
),
);
}
}
/// 血压计设备管理(已绑定/未绑定)
class DeviceManagementPage extends ConsumerWidget {
const DeviceManagementPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final device = ref.watch(omronDeviceProvider);
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('蓝牙血压计'),
),
body: device.isBound ? _buildBoundCard(context, ref, device) : _buildEmptyState(context, ref),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 88, height: 88,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(28),
),
child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint),
),
const SizedBox(height: 20),
const Text('未绑定设备', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const SizedBox(height: 8),
const Text('连接欧姆龙血压计,自动同步测量数据', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
const SizedBox(height: 28),
SizedBox(
width: 220,
height: 52,
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.add, size: 22),
label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
),
),
),
]),
),
);
Widget _buildBoundCard(BuildContext context, WidgetRef ref, DeviceBindState device) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [
// 设备信息卡片
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(children: [
Container(
width: 72, height: 72,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white),
),
const SizedBox(height: 16),
Text(device.name ?? '血压计', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 6),
Text('MAC: ${device.mac ?? ''}', style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
if (device.lastSync != null) ...[
const SizedBox(height: 4),
Text('上次同步: ${device.lastSync}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
],
const SizedBox(height: 24),
Row(children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref),
icon: const Icon(Icons.delete_outline, size: 18, color: AppColors.error),
label: const Text('解绑', style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.swap_horiz, size: 18),
label: const Text('更换设备'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
]),
]),
),
// 最后读数卡片
if (device.lastReading != null) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(children: [
Row(children: [
Container(
width: 6, height: 18,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
const Text('最近一次测量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]),
const SizedBox(height: 16),
Row(children: [
Container(
width: 56, height: 56,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: const Text('🩺', style: TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(device.lastReading!.display, style: const TextStyle(fontSize: 32, fontWeight: FontWeight.w800, color: AppColors.textPrimary, letterSpacing: -0.5)),
Text('mmHg', style: TextStyle(fontSize: 14, color: AppColors.textHint)),
]),
const Spacer(),
if (device.lastReading!.pulse != null)
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.primary)),
const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
]),
]),
]),
),
],
// 使用说明
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.primary),
const SizedBox(width: 8),
const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]),
const SizedBox(height: 10),
_guideItem('1', '血压计装好电池,绑好袖带'),
_guideItem('2', '血压计按开始键开始测量'),
_guideItem('3', '测量完成后数据自动同步到 App'),
_guideItem('4', '打开此页面时保持蓝牙开启'),
]),
),
]),
);
Widget _guideItem(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primary)),
const SizedBox(width: 6),
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
]),
);
void _unbind(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('解绑设备'),
content: const Text('解绑后需重新扫描连接,确定吗?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定解绑', style: TextStyle(color: AppColors.error))),
],
));
if (ok == true) {
await ref.read(omronDeviceProvider.notifier).unbind();
}
}
}
Widget _empty(BuildContext context, String title, String subtitle) =>
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.inbox_outlined, size: 64, color: AppColors.textHint),
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
]));