Files
AI-Health/health_app/lib/pages/remaining_pages.dart
MingNian b57d0d16f4 feat: 引入 EF Core Migrations + 后台任务/校验修复 + 前端四态
后端
- 改用 EF Core Migrations(InitialCreate),Program.cs 用 MigrateAsync + AUTO_MIGRATE 开关 + 显式 MigrationsAssembly;移除 EnsureCreated、删除手写 DatabaseSchemaMigrator
- 修复后台任务永久卡 Processing:三个队列对超时且达上限的任务原子标记 Failed
- 修复报告重试失效:基础设施异常向上抛激活 RetryAsync,停机取消不计失败
- 修复 AI 用药天数 off-by-one(结束日为包含式)
- AI 报告日志不再记录 VLM 健康正文
- 新增统一输入校验(ValidationException + 中间件映射 400):血压/心率/血糖/血氧/体重范围、药名/日期/服药时间去重、饮食热量与评分、运动时长上限
- 删除健康数据 AI 录入的影子直写路径,统一走 Service 校验

前端
- 新增 AppErrorState / AppFutureView,用药列表、服药打卡、运动计划、随访列表改为 加载/失败/空/数据 四态,失败可重试

瘦身
- 删除过时的 DataSeeder / DevDataSeeder 及调用
- 删除依赖实时服务的 ai_agent_tests 集成测试
2026-06-21 21:04:40 +08:00

2425 lines
79 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 '../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 '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
class DietRecordListPage extends ConsumerStatefulWidget {
const DietRecordListPage({super.key});
@override
ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
}
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
List<Map<String, dynamic>> _data = [];
bool _loading = true;
int _trendDays = 7;
DateTime _selectedDate = DateTime.now();
@override
void initState() {
super.initState();
_refresh();
}
Future<void> _refresh() async {
final records = await ref.read(dietServiceProvider).getRecords();
if (mounted) {
setState(() {
_data = records;
_loading = false;
});
}
}
Future<void> _delete(String id) async {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted) {
setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
}
}
String _dateKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
int _dayCalories(DateTime d) {
final key = _dateKey(d);
return _data
.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key))
.fold(0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0));
}
List<Map<String, dynamic>> _dayRecords(DateTime d) {
final key = _dateKey(d);
return _data
.where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key))
.toList();
}
List<double> _trendData() => List.generate(
_trendDays,
(i) => _dayCalories(
DateTime.now().subtract(Duration(days: _trendDays - 1 - i)),
).toDouble(),
);
@override
Widget build(BuildContext context) {
if (_loading) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'饮食记录',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: const Center(child: CircularProgressIndicator()),
);
}
final todayRecords = _dayRecords(_selectedDate);
final totalCal = todayRecords.fold(
0,
(s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0),
);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'饮食记录',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// 热量趋势
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'热量趋势',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
_Toggle(
'7天',
_trendDays == 7,
() => setState(() => _trendDays = 7),
),
const SizedBox(width: 6),
_Toggle(
'30天',
_trendDays == 30,
() => setState(() => _trendDays = 30),
),
],
),
const SizedBox(height: 10),
SizedBox(height: 120, child: _TrendChart(_trendData())),
],
),
),
const SizedBox(height: 12),
// 饮食日历
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'饮食日历',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(7, (i) {
final d = DateTime.now().subtract(Duration(days: 6 - i));
final cal = _dayCalories(d);
final isToday = _dateKey(d) == _dateKey(DateTime.now());
final isSel = _dateKey(d) == _dateKey(_selectedDate);
return GestureDetector(
onTap: () => setState(() => _selectedDate = d),
child: Container(
width: 42,
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSel
? AppColors.primary
: (isToday
? AppColors.primarySoft
: Colors.white),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSel
? AppColors.primary
: (isToday
? AppColors.primary
: AppColors.border),
),
),
child: Column(
children: [
Text(
['', '', '', '', '', '', ''][d.weekday -
1],
style: TextStyle(
fontSize: 10,
color: isSel
? Colors.white
: (isToday
? AppColors.primary
: AppColors.textHint),
),
),
Text(
'${d.day}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isSel
? Colors.white
: (isToday
? AppColors.primary
: AppColors.textPrimary),
),
),
if (cal > 0)
Text(
'$cal',
style: TextStyle(
fontSize: 9,
color: isSel
? Colors.white70
: AppColors.textHint,
),
),
],
),
),
);
}),
),
),
],
),
),
const SizedBox(height: 12),
// 当日详情
Row(
children: [
Text(
'${_selectedDate.month}${_selectedDate.day}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 8),
Text(
'$totalCal 千卡',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
const Spacer(),
Text(
'${todayRecords.length}',
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
],
),
const SizedBox(height: 8),
if (todayRecords.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text(
'当天暂无记录',
style: TextStyle(color: AppColors.textHint),
),
),
),
...todayRecords.map((d) {
final items =
(d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final mealNames = {
'Breakfast': '早餐',
'Lunch': '午餐',
'Dinner': '晚餐',
'Snack': '加餐',
};
final mealIcons = {
'Breakfast': Icons.wb_sunny,
'Lunch': Icons.wb_sunny_outlined,
'Dinner': Icons.nights_stay,
'Snack': Icons.cookie,
};
final mealLabel = mealNames[d['mealType']?.toString()] ?? '';
final mealIcon =
mealIcons[d['mealType']?.toString()] ?? Icons.restaurant;
final cal = d['totalCalories'] ?? 0;
final id = d['id']?.toString() ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: _SwipeAction(
onEdit: () => _showEditDialog(id, cal),
onDelete: () => _delete(id),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
mealIcon,
size: 20,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
mealLabel,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 8),
Text(
'$cal 千卡',
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
if (items.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
items.map((f) => f['name']).join(' · '),
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
),
),
);
}),
],
),
);
}
void _showEditDialog(String id, num cal) {
final ctrl = TextEditingController(text: '$cal');
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('修改热量'),
content: TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(labelText: '热量(千卡)'),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
Navigator.pop(ctx);
await ref.read(dietServiceProvider).updateRecord(id, {
'totalCalories': int.tryParse(ctrl.text) ?? 0,
});
_refresh();
},
child: const Text('保存'),
),
],
),
);
}
}
class _Toggle extends StatelessWidget {
final String l;
final bool a;
final VoidCallback t;
const _Toggle(this.l, this.a, this.t);
@override
Widget build(BuildContext c) => GestureDetector(
onTap: t,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: a ? AppColors.primary : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: a ? AppColors.primary : AppColors.border),
),
child: Text(
l,
style: TextStyle(
fontSize: 12,
color: a ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
class _TrendChart extends StatelessWidget {
final List<double> data;
const _TrendChart(this.data);
@override
Widget build(BuildContext c) {
if (data.isEmpty) {
return const Center(
child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)),
);
}
final m = data.reduce((a, b) => a > b ? a : b);
if (m == 0) {
return const Center(
child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(data.length, (i) {
final h = (data[i] / m * 100).clamp(4.0, 100.0);
return Expanded(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: data.length > 15 ? 1.0 : 2.0,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
children: [
if (data.length <= 14)
Text(
'${data[i].toInt()}',
style: const TextStyle(
fontSize: 8,
color: AppColors.textHint,
),
),
const SizedBox(height: 2),
Container(
height: h,
decoration: BoxDecoration(
color: data[i] > 0 ? AppColors.primary : AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
);
}),
);
}
}
class _SwipeAction extends StatelessWidget {
final Widget child;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _SwipeAction({
required this.child,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext c) => Dismissible(
key: UniqueKey(),
direction: DismissDirection.endToStart,
confirmDismiss: (_) async {
return false;
},
background: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: const Color(0xFF3B82F6),
),
alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 20),
child: const Icon(Icons.edit, color: Colors.white),
),
secondaryBackground: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
color: AppColors.error,
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => onDelete(),
onUpdate: (details) {
if (details.reached && details.direction == DismissDirection.startToEnd) {
onEdit();
}
},
child: child,
);
}
/// 饮食记录详情页(保留原功能)"""
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 GradientScaffold(
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 GradientScaffold(
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: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: AppFutureView<List<Map<String, dynamic>>>(
future: _future,
onRetry: _load,
errorTitle: '运动计划加载失败',
onData: (ctx, plans) {
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 startDate = p['startDate']?.toString() ?? '';
final endDate = p['endDate']?.toString() ?? '';
final now = DateTime.now();
final todayKey = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
(it) => it['scheduledDate']?.toString() == todayKey,
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(
'$startDate$endDate · ${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: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: todayDone
? AppColors.successLight
: 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');
final _daysCtrl = TextEditingController(text: '7');
DateTime _start = DateTime.now();
TimeOfDay _reminderTime = const TimeOfDay(hour: 19, minute: 0);
@override
void dispose() {
_nameCtrl.dispose();
_durationCtrl.dispose();
_daysCtrl.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;
final days = (int.tryParse(_daysCtrl.text) ?? 7).clamp(1, 366);
final end = _start.add(Duration(days: days - 1));
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')}',
'reminderTime': '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}',
});
ref.invalidate(currentExercisePlanProvider);
if (mounted) {
popRoute(ref);
}
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
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),
_field('坚持天数', _daysCtrl, hint: '7', number: true),
const SizedBox(height: 16),
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
const SizedBox(height: 16),
_timeField(),
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: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(
color: AppColors.primary,
width: 1.5,
),
),
),
style: const TextStyle(fontSize: 17),
),
],
);
}
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),
),
),
),
],
);
}
Widget _timeField() {
final value = '${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('每日提醒时间', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: context, initialTime: _reminderTime);
if (picked != null && mounted) setState(() => _reminderTime = picked);
},
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Text(value, 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 GradientScaffold(
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),
);
}
if (snap.hasError) {
return AppErrorState(title: '随访加载失败', onRetry: _load);
}
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();
String _birthDate = '';
final _diagnosisCtrl = TextEditingController();
final List<Map<String, String>> _surgeries = [];
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();
_diagnosisCtrl.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'] ?? '';
_birthDate = p['birthDate'] ?? '';
}
final a = await srv.getHealthArchive();
if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
final surgeries = a['surgeries'] as List?;
if (surgeries != null && surgeries.isNotEmpty) {
_surgeries.addAll(surgeries.whereType<Map>().map((item) => {
'type': item['type']?.toString() ?? '',
'date': item['date']?.toString() ?? '',
}));
} else {
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty) {
_surgeries.add({'type': st, 'date': sd ?? ''});
}
}
_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> _pickDate(void Function(String) onPicked) async {
final now = DateTime.now();
final d = await showDatePicker(
context: context,
initialDate: DateTime(now.year - 30),
firstDate: DateTime(1900),
lastDate: now,
locale: const Locale('zh'),
);
if (d != null) {
onPicked(
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}',
);
}
}
void _addSurgery() =>
setState(() => _surgeries.add({'type': '', 'date': ''}));
void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i));
Future<void> _save() async {
setState(() => _saving = true);
try {
final srv = ref.read(userServiceProvider);
await srv.updateProfile(
name: _nameCtrl.text,
gender: _genderCtrl.text,
birthDate: _birthDate,
);
final surgeries = _surgeries
.where((s) => (s['type'] ?? '').trim().isNotEmpty)
.map((s) => {
'type': s['type']!.trim(),
'date': (s['date'] ?? '').trim().isEmpty ? null : s['date']!.trim(),
})
.toList();
final firstSurgery = surgeries.isEmpty ? null : surgeries.first;
await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text,
'surgeryType': firstSurgery?['type'],
'surgeryDate': firstSurgery?['date'],
'surgeries': surgeries,
'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,
});
await ref.read(authProvider.notifier).refreshProfile();
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 GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'健康档案',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: const Center(child: CircularProgressIndicator()),
);
}
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'健康档案',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_Section('个人资料', Icons.person_outline, [
_F('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
_F('性别', _genderCtrl, hint: '男/女'),
const SizedBox(height: 12),
_DateF(
'出生日期',
_birthDate,
() => _pickDate((v) => setState(() => _birthDate = v)),
),
]),
const SizedBox(height: 12),
_Section('医疗信息', Icons.local_hospital_outlined, [
_F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
const SizedBox(height: 14),
Row(
children: [
const Text(
'手术史',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const Spacer(),
_AddBtn(() => _addSurgery()),
],
),
..._surgeries.asMap().entries.map((e) {
final i = e.key;
final s = e.value;
return Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(
children: [
Expanded(
child: _F2(
'手术',
s['type'] ?? '',
(v) => s['type'] = v,
hint: '如: 心脏搭桥',
),
),
const SizedBox(width: 8),
Expanded(
child: _DateF2(
'日期',
s['date'] ?? '',
() => _pickDate((v) => s['date'] = v),
hint: '点击选择',
),
),
GestureDetector(
onTap: () => _removeSurgery(i),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: BorderRadius.circular(8),
),
child: const Icon(
Icons.close,
size: 16,
color: AppColors.error,
),
),
),
],
),
);
}),
]),
const SizedBox(height: 12),
_Section('病史与限制', Icons.warning_amber_outlined, [
_F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 24),
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
gradient: AppColors.primaryGradient,
),
padding: const EdgeInsets.all(1.5),
child: SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.primary,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(13),
),
elevation: 0,
),
child: Text(
_saving ? '保存中...' : '保存档案',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
),
),
),
const SizedBox(height: 40),
],
),
);
}
}
class _Section extends StatelessWidget {
final String title;
final IconData icon;
final List<Widget> fields;
const _Section(this.title, this.icon, this.fields);
@override
Widget build(BuildContext c) => Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
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,
],
),
);
}
class _F extends StatelessWidget {
final String label;
final TextEditingController ctrl;
final String? hint;
const _F(this.label, this.ctrl, {this.hint});
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(fontSize: 16),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
],
);
}
class _F2 extends StatelessWidget {
final String label;
final String value;
final void Function(String) chg;
final String? hint;
const _F2(this.label, this.value, this.chg, {this.hint});
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
TextField(
controller: TextEditingController(text: value),
onChanged: chg,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint),
filled: true,
fillColor: Colors.white,
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.border),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: AppColors.primary),
),
),
),
],
);
}
class _DateF extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
const _DateF(this.label, this.value, this.onTap);
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
GestureDetector(
onTap: onTap,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: AppColors.border),
),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(
value.isEmpty ? '点击选择日期' : value,
style: TextStyle(
fontSize: 16,
color: value.isEmpty
? AppColors.textHint
: AppColors.textPrimary,
),
),
const Spacer(),
const Icon(
Icons.calendar_today,
size: 18,
color: AppColors.textHint,
),
],
),
),
),
],
);
}
class _DateF2 extends StatelessWidget {
final String label;
final String value;
final VoidCallback onTap;
final String? hint;
const _DateF2(this.label, this.value, this.onTap, {this.hint});
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
GestureDetector(
onTap: onTap,
child: Container(
height: 44,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: AppColors.border),
),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(
value.isEmpty ? (hint ?? '点击选择') : value,
style: TextStyle(
fontSize: 13,
color: value.isEmpty
? AppColors.textHint
: AppColors.textPrimary,
),
),
const Spacer(),
const Icon(
Icons.calendar_today,
size: 14,
color: AppColors.textHint,
),
],
),
),
),
],
);
}
class _AddBtn extends StatelessWidget {
final VoidCallback onTap;
const _AddBtn(this.onTap);
@override
Widget build(BuildContext c) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: BorderRadius.circular(8),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 14, color: AppColors.primary),
SizedBox(width: 2),
Text(
'添加',
style: TextStyle(fontSize: 13, color: AppColors.primary),
),
],
),
),
);
}
}
/// 健康日历
class HealthCalendarPage extends ConsumerStatefulWidget {
const HealthCalendarPage({super.key});
@override
ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
}
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
DateTime? _selectedDate;
Map<String, List<Map<String, dynamic>>> _events = {};
Map<String, dynamic>? _selectedDayData;
static const _medBlue = Color(0xFF0000FF);
static const _exGreen = Color(0xFF00FF00);
static const _fupOrange = Color(0xFFFFA500);
@override
void initState() {
super.initState();
_loadMonth();
_selectDate(DateTime.now());
}
String _dateKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
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<Map<String, dynamic>>>{};
for (final item in list) {
final map = item as Map<String, dynamic>;
final types = (map['events'] as List?)?.cast<String>() ?? [];
events[map['date'] as String] = types
.map((t) => <String, dynamic>{'type': t})
.toList();
}
if (mounted) setState(() => _events = events);
// 如果有选中的日期,刷新详情
if (_selectedDate != null) _selectDate(_selectedDate!);
} catch (_) {}
}
void _selectDate(DateTime d) {
setState(() {
_selectedDate = d;
_selectedDayData = null;
});
final key = _dateKey(d);
final details = _events[key];
if (details != null && details.isNotEmpty) {
// 从后端拉详细数据
_loadDayDetail(d);
}
}
Future<void> _loadDayDetail(DateTime d) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get(
'/api/calendar/day',
queryParameters: {'date': _dateKey(d)},
);
if (mounted) {
setState(
() => _selectedDayData = res.data['data'] as Map<String, dynamic>?,
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
surfaceTintColor: Colors.transparent,
title: const Text(
'健康日历',
style: TextStyle(color: AppColors.textPrimary),
),
leading: IconButton(
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
onPressed: () => popRoute(ref),
),
),
body: Column(
children: [
Container(
color: Colors.white,
child: Column(
children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
],
),
),
const SizedBox(height: 12),
// 图例
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Legend('用药提醒', _medBlue),
const SizedBox(width: 20),
_Legend('运动计划', _exGreen),
const SizedBox(width: 20),
_Legend('复查随访', _fupOrange),
],
),
),
const SizedBox(height: 12),
// 当日详情
Expanded(child: _buildDayDetail()),
],
),
);
}
Widget _buildMonthHeader() => Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () {
setState(
() => _currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month - 1,
),
);
_loadMonth();
},
),
Text(
'${_currentMonth.year}${_currentMonth.month}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.chevron_right),
onPressed: () {
setState(
() => _currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
),
);
_loadMonth();
},
),
],
),
);
Widget _buildWeekdayHeader() {
const wd = ['', '', '', '', '', '', ''];
return Row(
children: wd
.map(
(d) => Expanded(
child: Center(
child: Text(
d,
style: const TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
),
),
)
.toList(),
);
}
Widget _buildCalendarGrid() {
final first = DateTime(_currentMonth.year, _currentMonth.month, 1);
final last = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final dim = last.day;
final sw = first.weekday % 7;
final days = List.generate(42, (i) {
final di = i - sw;
return di < 0 || di >= dim ? null : di + 1;
});
final today = DateTime.now();
return GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 7,
),
itemCount: 42,
itemBuilder: (_, i) {
final day = days[i];
if (day == null) return const SizedBox();
final d = DateTime(_currentMonth.year, _currentMonth.month, day);
final isToday = _dateKey(d) == _dateKey(today);
final isSel =
_selectedDate != null && _dateKey(d) == _dateKey(_selectedDate!);
final evs = _events[_dateKey(d)] ?? [];
return GestureDetector(
onTap: () => _selectDate(d),
child: Container(
margin: const EdgeInsets.all(2),
decoration: BoxDecoration(
color: isSel
? AppColors.primarySoft
: (isToday ? AppColors.infoLight : Colors.white),
borderRadius: BorderRadius.circular(12),
border: isSel
? Border.all(color: AppColors.primary, width: 1.5)
: (isToday
? Border.all(
color: AppColors.primary.withValues(alpha: 0.3),
width: 1,
)
: null),
),
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 16,
color: isSel ? AppColors.primary : AppColors.textPrimary,
fontWeight: isSel || isToday
? FontWeight.w600
: FontWeight.normal,
),
),
if (evs.isNotEmpty)
Positioned(
bottom: 4,
child: Row(
children: evs
.take(3)
.map(
(e) => Container(
width: 5,
height: 5,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _dotColor(e['type']?.toString() ?? ''),
shape: BoxShape.circle,
),
),
)
.toList(),
),
),
],
),
),
);
},
);
}
Color _dotColor(String t) => switch (t) {
'medication' => _medBlue,
'exercise' => _exGreen,
'followup' => _fupOrange,
_ => AppColors.textHint,
};
Widget _buildDayDetail() {
if (_selectedDate == null) {
return const Center(
child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)),
);
}
if (_selectedDayData == null) {
final evs = _events[_dateKey(_selectedDate!)] ?? [];
if (evs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.event_available,
size: 40,
color: AppColors.textHint,
),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
}
final data = _selectedDayData;
if (data == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
final meds =
(data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final exs =
(data['exercises'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final fups =
(data['followUps'] as List?)?.cast<Map<String, dynamic>>() ?? [];
if (meds.isEmpty && exs.isEmpty && fups.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// 用药
if (meds.isNotEmpty) ...[
_SectionTitle('用药提醒', _medBlue, Icons.medication),
...meds.map(
(m) => _EventCard(
'${m['name'] ?? ''} ${m['dosage'] ?? ''}',
'${m['timeOfDay'] ?? ''}',
_medBlue,
),
),
],
// 运动
if (exs.isNotEmpty) ...[
_SectionTitle('运动计划', _exGreen, Icons.fitness_center),
...exs.map(
(e) => _EventCard(
'${e['type'] ?? ''}',
'${e['duration'] ?? 0}分钟',
_exGreen,
),
),
],
// 随访
if (fups.isNotEmpty) ...[
_SectionTitle('复查随访', _fupOrange, Icons.event_note),
...fups.map(
(f) =>
_EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange),
),
],
],
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
final Color color;
final IconData icon;
const _SectionTitle(this.title, this.color, this.icon);
@override
Widget build(BuildContext c) => Padding(
padding: const EdgeInsets.only(top: 12, bottom: 6),
child: Row(
children: [
Container(
width: 4,
height: 16,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Icon(icon, size: 16, color: color),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: color,
),
),
],
),
);
}
class _EventCard extends StatelessWidget {
final String title;
final String sub;
final Color color;
const _EventCard(this.title, this.sub, this.color);
@override
Widget build(BuildContext c) => Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500),
),
),
Text(
sub,
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
),
);
}
class _Legend extends StatelessWidget {
final String label;
final Color color;
const _Legend(this.label, this.color);
@override
Widget build(BuildContext c) => Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
],
);
}
/// 静态文本页
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 GradientScaffold(
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,
),
),
),
);
}
}
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),
],
),
);