后端 - 改用 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 集成测试
2425 lines
79 KiB
Dart
2425 lines
79 KiB
Dart
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),
|
||
],
|
||
),
|
||
);
|