## 后端 - 健康档案: 新增手术状态字段 + EF 迁移; HealthArchiveService 新增查询方法 - 健康记录: HealthRecordService 新增批量/统计方法; 契约扩展 - 用药: 新增 MedicationScheduleStatus 枚举; MedicationService 排班逻辑调整 - 通知: EfUserNotificationPipeline 重构; 新增 EfReminderCatchUpService; 通知管线支持更多场景 - 用户: UserService 账号删除逻辑; 新增 local_account_file_cleanup; EfUserRepository 扩展 - AI: medication_agent_handler 微调; prompt_manager 优化; AiConversationService 上下文处理 - Endpoint: doctor/medication/exercise/health/notification/user 等多接口调整 - BackgroundService: health_record_reminder_service 重构, 提醒补漏逻辑 - 测试: 新增 account_deletion/doctor_endpoint/medication_schedule/medication_update/prompt_manager 测试 ## 前端 - UI 系统: app_theme 大幅重构; app_colors/app_design_tokens/app_module_visuals 调整; 二级页面色彩刷新 - 主页: home_page 背景渐变 + 消息列表提取 _HomeMessages + 通知检查逻辑; chat_messages_view 全面重构 - 用药: medication_list/edit/checkin 三页重构, 新增 medication_ui_logic 抽取 - 通知: notification_prefs_page 重构, 新增 notification_prefs_logic; notification_center 优化 - 设备: device_management 重构, 新增 device_sync_ui_logic; device_scan 优化 - 趋势图: trend_page 大幅重构 - 登录: login_page 重构 - 个人资料: 新增 profile_edit_page; profile_page 优化 - 运动: 新增 exercise/ 目录 + care_plan_ui_logic - 其他: remaining_pages/report_pages/health_drawer/admin/doctor 等多页面调整 - 组件: common_widgets/app_empty_state/app_error_state/app_future_view/app_toast/ai_content 优化 - Provider: chat_provider/consultation_provider/data_providers/auth_provider 调整 - AndroidManifest: 移除多余权限 - 测试: 新增 ai_content/care_plan/home_message/login_flow/medication_checkin/medication_ui/notification_prefs/profile_device/secondary_page/swipe_delete 等大量测试 ## 文档 - 新增 ui-design-system.md 设计系统文档 - 新增 secondary-page-color-refresh 计划 + specs 目录
4759 lines
161 KiB
Dart
4759 lines
161 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||
import '../core/app_colors.dart';
|
||
import '../core/app_design_tokens.dart';
|
||
import '../core/app_module_visuals.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_empty_state.dart';
|
||
import '../widgets/app_error_state.dart';
|
||
import '../widgets/app_future_view.dart';
|
||
import '../widgets/app_toast.dart';
|
||
import 'diet/diet_nutrition_widgets.dart';
|
||
import 'diet/diet_record_logic.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 {
|
||
final index = _data.indexWhere((d) => d['id']?.toString() == id);
|
||
if (index < 0) return;
|
||
final removed = _data[index];
|
||
setState(() => _data.removeAt(index));
|
||
try {
|
||
await ref.read(dietServiceProvider).deleteRecord(id);
|
||
if (mounted) {
|
||
AppToast.show(context, '已删除', type: AppToastType.success);
|
||
}
|
||
} catch (_) {
|
||
if (!mounted) return;
|
||
setState(() => _data.insert(index.clamp(0, _data.length), removed));
|
||
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
|
||
}
|
||
}
|
||
|
||
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 isSelectedToday = _dateKey(_selectedDate) == _dateKey(DateTime.now());
|
||
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.fromLTRB(16, 14, 16, 32),
|
||
children: [
|
||
_DietDaySummary(
|
||
date: _selectedDate,
|
||
totalCalories: totalCal,
|
||
count: todayRecords.length,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_buildDateStrip(),
|
||
const SizedBox(height: 18),
|
||
Row(
|
||
children: [
|
||
Text(
|
||
isSelectedToday
|
||
? '今天的饮食'
|
||
: '${_selectedDate.month}月${_selectedDate.day}日饮食',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
Text(
|
||
'${todayRecords.length} 条 · $totalCal 千卡',
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
if (todayRecords.isEmpty)
|
||
const _DietEmptyDay()
|
||
else
|
||
ClipRRect(
|
||
borderRadius: AppRadius.mdBorder,
|
||
child: ColoredBox(
|
||
color: Colors.white,
|
||
child: Column(
|
||
children: [
|
||
for (var i = 0; i < todayRecords.length; i++)
|
||
_buildDietRecordRow(
|
||
todayRecords[i],
|
||
showDivider: i < todayRecords.length - 1,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 18),
|
||
_DietTrendPanel(
|
||
trendDays: _trendDays,
|
||
trendData: _trendData(),
|
||
onSelectDays: (days) => setState(() => _trendDays = days),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildDateStrip() {
|
||
final dates = recentDietDates(DateTime.now());
|
||
return Row(
|
||
children: List.generate(dates.length, (i) {
|
||
final d = dates[i];
|
||
final cal = _dayCalories(d);
|
||
final isToday = _dateKey(d) == _dateKey(DateTime.now());
|
||
final isSel = _dateKey(d) == _dateKey(_selectedDate);
|
||
return Expanded(
|
||
child: Padding(
|
||
padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5),
|
||
child: GestureDetector(
|
||
onTap: () => setState(() => _selectedDate = d),
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||
decoration: BoxDecoration(
|
||
color: isSel
|
||
? AppColors.primary
|
||
: (isToday ? DietPalette.primarySoft : Colors.white),
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: Border.all(
|
||
color: isSel
|
||
? Colors.transparent
|
||
: (isToday
|
||
? const Color(0xFFC9D0FF)
|
||
: AppColors.borderLight),
|
||
),
|
||
),
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
child: Text(
|
||
isToday
|
||
? '今天'
|
||
: ['一', '二', '三', '四', '五', '六', '日'][d.weekday -
|
||
1],
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w700,
|
||
color: isSel ? Colors.white : AppColors.textHint,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'${d.day}',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w900,
|
||
color: isSel ? Colors.white : AppColors.textPrimary,
|
||
),
|
||
),
|
||
if (cal > 0) ...[
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
'$cal',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.w600,
|
||
color: isSel ? Colors.white70 : AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
|
||
Widget _buildDietRecordRow(
|
||
Map<String, dynamic> record, {
|
||
required bool showDivider,
|
||
}) {
|
||
final items =
|
||
(record['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[record['mealType']?.toString()] ?? '';
|
||
final mealIcon =
|
||
mealIcons[record['mealType']?.toString()] ?? Icons.restaurant;
|
||
final cal = record['totalCalories'] ?? 0;
|
||
final id = record['id']?.toString() ?? '';
|
||
|
||
return _SwipeAction(
|
||
itemKey: ValueKey(id),
|
||
onEdit: () => _showEditDialog(id, cal),
|
||
onDelete: () => _delete(id),
|
||
child: Material(
|
||
color: Colors.white,
|
||
child: InkWell(
|
||
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': id}),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
child: Column(
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 36,
|
||
height: 36,
|
||
decoration: BoxDecoration(
|
||
color: DietPalette.primarySoft,
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Icon(
|
||
mealIcon,
|
||
size: 19,
|
||
color: DietPalette.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
mealLabel,
|
||
style: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
'$cal 千卡',
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
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: 14,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textHint,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
if (showDivider)
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 48, top: 12),
|
||
child: Divider(
|
||
height: 1,
|
||
thickness: 0.7,
|
||
color: Color(0xFFE8ECF2),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
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 _DietDaySummary extends StatelessWidget {
|
||
final DateTime date;
|
||
final int totalCalories;
|
||
final int count;
|
||
|
||
const _DietDaySummary({
|
||
required this.date,
|
||
required this.totalCalories,
|
||
required this.count,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 15, 16, 14),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.xlBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'${date.month}月${date.day}日',
|
||
style: const TextStyle(
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w900,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 5),
|
||
Text(
|
||
'共 $totalCalories 千卡 · $count 条记录',
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: DietPalette.primarySoft,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Icon(
|
||
AppModuleVisuals.diet.icon,
|
||
size: 22,
|
||
color: DietPalette.primary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DietEmptyDay extends StatelessWidget {
|
||
const _DietEmptyDay();
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 18),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
color: DietPalette.primarySoft,
|
||
shape: BoxShape.circle,
|
||
),
|
||
child: Icon(
|
||
AppModuleVisuals.diet.icon,
|
||
size: 21,
|
||
color: DietPalette.primary,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
const Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'当天没有饮食记录',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
SizedBox(height: 3),
|
||
Text(
|
||
'记录后会在这里汇总热量和食物明细',
|
||
style: TextStyle(fontSize: 13, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _DietTrendPanel extends StatelessWidget {
|
||
final int trendDays;
|
||
final List<double> trendData;
|
||
final ValueChanged<int> onSelectDays;
|
||
|
||
const _DietTrendPanel({
|
||
required this.trendDays,
|
||
required this.trendData,
|
||
required this.onSelectDays,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 14),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.xlBorder,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Text(
|
||
'热量趋势',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
_Toggle('7天', trendDays == 7, () => onSelectDays(7)),
|
||
const SizedBox(width: 6),
|
||
_Toggle('30天', trendDays == 30, () => onSelectDays(30)),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
SizedBox(height: 112, child: _TrendChart(trendData)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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: AppRadius.mdBorder,
|
||
border: Border.all(color: a ? Colors.transparent : 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 ? DietPalette.primary : AppColors.border,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _SwipeAction extends StatelessWidget {
|
||
final Key itemKey;
|
||
final Widget child;
|
||
final VoidCallback onEdit;
|
||
final VoidCallback onDelete;
|
||
const _SwipeAction({
|
||
required this.itemKey,
|
||
required this.child,
|
||
required this.onEdit,
|
||
required this.onDelete,
|
||
});
|
||
@override
|
||
Widget build(BuildContext c) => Dismissible(
|
||
key: itemKey,
|
||
direction: DismissDirection.horizontal,
|
||
confirmDismiss: (direction) async {
|
||
if (dietRecordSwipeAction(direction) == DietRecordSwipeAction.edit) {
|
||
onEdit();
|
||
return false;
|
||
}
|
||
return true;
|
||
},
|
||
background: Container(
|
||
decoration: BoxDecoration(
|
||
borderRadius: AppRadius.mdBorder,
|
||
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: AppRadius.mdBorder,
|
||
color: AppColors.error,
|
||
),
|
||
alignment: Alignment.centerRight,
|
||
padding: const EdgeInsets.only(right: 20),
|
||
child: const Icon(Icons.delete, color: Colors.white),
|
||
),
|
||
onDismissed: (_) => onDelete(),
|
||
child: child,
|
||
);
|
||
}
|
||
|
||
/// 饮食记录详情页
|
||
class DietRecordDetailPage extends ConsumerStatefulWidget {
|
||
final String id;
|
||
const DietRecordDetailPage({super.key, required this.id});
|
||
|
||
@override
|
||
ConsumerState<DietRecordDetailPage> createState() =>
|
||
_DietRecordDetailPageState();
|
||
}
|
||
|
||
class _DietRecordDetailPageState extends ConsumerState<DietRecordDetailPage> {
|
||
late Future<List<Map<String, dynamic>>> _recordsFuture;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_recordsFuture = _loadRecords();
|
||
}
|
||
|
||
Future<List<Map<String, dynamic>>> _loadRecords() =>
|
||
ref.read(dietServiceProvider).getRecords();
|
||
|
||
void _reload() => setState(() => _recordsFuture = _loadRecords());
|
||
|
||
String _formatRecordedAt(Object? value) {
|
||
final date = DateTime.tryParse(value?.toString() ?? '')?.toLocal();
|
||
if (date == null) return '';
|
||
final now = DateTime.now();
|
||
final prefix = date.year == now.year
|
||
? '${date.month}-${date.day}'
|
||
: '${date.year}-${date.month}-${date.day}';
|
||
return '$prefix ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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: _recordsFuture,
|
||
builder: (ctx, snap) {
|
||
if (!snap.hasData) {
|
||
if (snap.hasError) {
|
||
return AppErrorState(
|
||
title: '饮食记录加载失败',
|
||
subtitle: '请检查网络后重新进入',
|
||
onRetry: _reload,
|
||
);
|
||
}
|
||
return const Center(
|
||
child: CircularProgressIndicator(color: AppTheme.primary),
|
||
);
|
||
}
|
||
final d = snap.data!.firstWhere(
|
||
(r) => r['id']?.toString() == widget.id,
|
||
orElse: () => <String, dynamic>{},
|
||
);
|
||
if (d.isEmpty) {
|
||
return AppEmptyState(
|
||
icon: AppModuleVisuals.diet.icon,
|
||
iconColor: DietPalette.primary,
|
||
title: '这条饮食记录不存在',
|
||
subtitle: '记录可能已经被删除',
|
||
);
|
||
}
|
||
final items =
|
||
(d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final totalCal = (d['totalCalories'] as num?)?.toInt() ?? 0;
|
||
final protein = (totalCal * 0.16 / 4).round();
|
||
final carbs = (totalCal * 0.52 / 4).round();
|
||
final fat = (totalCal * 0.28 / 9).round();
|
||
final mealNames = {
|
||
'Breakfast': '早餐',
|
||
'Lunch': '午餐',
|
||
'Dinner': '晚餐',
|
||
'Snack': '加餐',
|
||
};
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.xlBorder,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
mealNames[d['mealType']?.toString()] ?? '饮食记录',
|
||
style: const TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
Text(
|
||
_formatRecordedAt(d['recordedAt']),
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textHint,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 20),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
DietCalorieRing(calories: totalCal),
|
||
const SizedBox(width: 20),
|
||
Expanded(
|
||
child: Column(
|
||
children: [
|
||
DietMacroBar(
|
||
label: '蛋白质',
|
||
value: protein,
|
||
color: DietPalette.protein,
|
||
reference: 50,
|
||
),
|
||
const SizedBox(height: 14),
|
||
DietMacroBar(
|
||
label: '碳水',
|
||
value: carbs,
|
||
color: DietPalette.carbs,
|
||
reference: 100,
|
||
),
|
||
const SizedBox(height: 14),
|
||
DietMacroBar(
|
||
label: '脂肪',
|
||
value: fat,
|
||
color: DietPalette.fat,
|
||
reference: 35,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'营养数据根据本餐总热量估算,仅供日常记录参考',
|
||
style: TextStyle(fontSize: 11, color: AppColors.textHint),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
const Text('识别结果', style: AppTextStyles.sectionTitle),
|
||
const SizedBox(height: 8),
|
||
ClipRRect(
|
||
borderRadius: AppRadius.xlBorder,
|
||
child: ColoredBox(
|
||
color: Colors.white,
|
||
child: Column(
|
||
children: [
|
||
if (items.isEmpty)
|
||
const Padding(
|
||
padding: EdgeInsets.all(18),
|
||
child: Text(
|
||
'这条记录没有食物明细',
|
||
style: TextStyle(color: AppColors.textHint),
|
||
),
|
||
),
|
||
for (var i = 0; i < items.length; i++) ...[
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 15,
|
||
vertical: 14,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
items[i]['name']?.toString() ?? '',
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 86,
|
||
child: Text(
|
||
items[i]['portion']?.toString() ?? '',
|
||
textAlign: TextAlign.center,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
SizedBox(
|
||
width: 78,
|
||
child: Text(
|
||
'${items[i]['calories'] ?? 0} 千卡',
|
||
textAlign: TextAlign.right,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: DietPalette.calorie,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (i < items.length - 1)
|
||
const Padding(
|
||
padding: EdgeInsets.only(left: 15),
|
||
child: Divider(
|
||
height: 1,
|
||
thickness: 0.7,
|
||
color: Color(0xFFE8ECF2),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 运动计划列表(含打卡)
|
||
class ExercisePlanPage extends ConsumerStatefulWidget {
|
||
const ExercisePlanPage({super.key});
|
||
@override
|
||
ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
|
||
}
|
||
|
||
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||
static const _exerciseVisual = AppModuleVisuals.exercise;
|
||
|
||
Future<List<Map<String, dynamic>>>? _future;
|
||
final Set<String> _busyItems = {};
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
void _load() => setState(() {
|
||
_future = ref.read(exerciseServiceProvider).getPlans();
|
||
});
|
||
|
||
Future<void> _refresh() async {
|
||
_load();
|
||
await _future;
|
||
}
|
||
|
||
Future<void> _checkIn(String itemId) async {
|
||
if (itemId.isEmpty || _busyItems.contains(itemId)) return;
|
||
setState(() => _busyItems.add(itemId));
|
||
try {
|
||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
_load();
|
||
} catch (e) {
|
||
debugPrint('[ExercisePlan] 打卡失败: $e');
|
||
if (mounted) {
|
||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||
}
|
||
}
|
||
|
||
Future<void> _deletePlan(String id) async {
|
||
await ref.read(exerciseServiceProvider).deletePlan(id);
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
_load();
|
||
}
|
||
|
||
String _todayKey() {
|
||
final now = DateTime.now();
|
||
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
title: const Text('运动计划'),
|
||
),
|
||
floatingActionButton: _ModuleGradientFab(
|
||
gradient: _exerciseVisual.gradient,
|
||
shadowColor: _exerciseVisual.color,
|
||
onTap: () => pushRoute(ref, 'exerciseCreate'),
|
||
),
|
||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||
future: _future,
|
||
onRetry: _load,
|
||
errorTitle: '运动计划加载失败',
|
||
onData: (ctx, plans) {
|
||
if (plans.isEmpty) {
|
||
return _empty(context, '运动计划', '暂无计划,点击右下角新建');
|
||
}
|
||
final todayKey = _todayKey();
|
||
final todayItems = plans
|
||
.expand(
|
||
(p) =>
|
||
((p['items'] as List?)?.cast<Map<String, dynamic>>() ??
|
||
<Map<String, dynamic>>[]),
|
||
)
|
||
.where((item) => item['scheduledDate']?.toString() == todayKey)
|
||
.toList();
|
||
final todayDone = todayItems
|
||
.where((item) => item['isCompleted'] == true)
|
||
.length;
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: _refresh,
|
||
child: ListView(
|
||
physics: const AlwaysScrollableScrollPhysics(),
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
||
children: [
|
||
_ExerciseCheckInSummary(
|
||
planCount: plans.length,
|
||
totalToday: todayItems.length,
|
||
doneToday: todayDone,
|
||
),
|
||
const SizedBox(height: 10),
|
||
...List.generate(plans.length, (i) {
|
||
final p = plans[i];
|
||
final items =
|
||
(p['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
if (items.isEmpty) return const SizedBox.shrink();
|
||
|
||
return SwipeDeleteTile(
|
||
key: Key(p['id']?.toString() ?? '$i'),
|
||
onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
|
||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||
child: _ExercisePlanOverviewCard(
|
||
plan: p,
|
||
items: items,
|
||
todayKey: todayKey,
|
||
busyItems: _busyItems,
|
||
onCheckIn: _checkIn,
|
||
),
|
||
);
|
||
}),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ModuleGradientFab extends StatelessWidget {
|
||
final Gradient gradient;
|
||
final Color shadowColor;
|
||
final VoidCallback onTap;
|
||
|
||
const _ModuleGradientFab({
|
||
required this.gradient,
|
||
required this.shadowColor,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
width: 56,
|
||
height: 56,
|
||
decoration: BoxDecoration(
|
||
gradient: gradient,
|
||
borderRadius: AppRadius.lgBorder,
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: shadowColor.withValues(alpha: 0.22),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 8),
|
||
),
|
||
],
|
||
),
|
||
child: Material(
|
||
color: Colors.transparent,
|
||
borderRadius: AppRadius.lgBorder,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: AppRadius.lgBorder,
|
||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExercisePlanOverviewCard extends StatelessWidget {
|
||
final Map<String, dynamic> plan;
|
||
final List<Map<String, dynamic>> items;
|
||
final String todayKey;
|
||
final Set<String> busyItems;
|
||
final Future<void> Function(String itemId) onCheckIn;
|
||
|
||
const _ExercisePlanOverviewCard({
|
||
required this.plan,
|
||
required this.items,
|
||
required this.todayKey,
|
||
required this.busyItems,
|
||
required this.onCheckIn,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final sortedItems = [...items]
|
||
..sort(
|
||
(a, b) => (a['scheduledDate']?.toString() ?? '').compareTo(
|
||
b['scheduledDate']?.toString() ?? '',
|
||
),
|
||
);
|
||
final total = sortedItems.length;
|
||
final done = sortedItems.where((it) => it['isCompleted'] == true).length;
|
||
final progress = total == 0 ? 0.0 : done / total;
|
||
final todayItem = sortedItems.firstWhere(
|
||
(it) => it['scheduledDate']?.toString() == todayKey,
|
||
orElse: () => <String, dynamic>{},
|
||
);
|
||
final exerciseName = _exerciseSummary(sortedItems);
|
||
final startDate = plan['startDate']?.toString() ?? '';
|
||
final endDate = plan['endDate']?.toString() ?? '';
|
||
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border, width: 1.1),
|
||
boxShadow: [AppTheme.shadowLight],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
gradient: _ExercisePlanPageState._exerciseVisual.gradient,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Icon(
|
||
_ExercisePlanPageState._exerciseVisual.icon,
|
||
color: Colors.white,
|
||
size: 24,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
exerciseName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 19,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
'${_slashDate(startDate)} - ${_slashDate(endDate)}',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
color: AppTheme.textSub,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 5,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color: _ExercisePlanPageState._exerciseVisual.lightColor,
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(
|
||
color: _ExercisePlanPageState._exerciseVisual.borderColor,
|
||
),
|
||
),
|
||
child: Text(
|
||
'$done/$total 天',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w800,
|
||
color: _ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 8,
|
||
value: progress.clamp(0.0, 1.0),
|
||
backgroundColor: AppColors.cardInner,
|
||
valueColor: AlwaysStoppedAnimation<Color>(
|
||
_ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_ExerciseTodayInlineTask(
|
||
item: todayItem,
|
||
isBusy: busyItems.contains(todayItem['id']?.toString() ?? ''),
|
||
onCheckIn: onCheckIn,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
String _exerciseSummary(List<Map<String, dynamic>> source) {
|
||
final names = source
|
||
.where((item) => item['isRestDay'] != true)
|
||
.map((item) => item['exerciseType']?.toString() ?? '')
|
||
.where((name) => name.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
if (names.isEmpty) return '运动计划';
|
||
if (names.length == 1) return names.first;
|
||
return '${names.first}等 ${names.length} 项';
|
||
}
|
||
}
|
||
|
||
class _ExerciseTodayInlineTask extends StatelessWidget {
|
||
final Map<String, dynamic> item;
|
||
final bool isBusy;
|
||
final Future<void> Function(String itemId) onCheckIn;
|
||
|
||
const _ExerciseTodayInlineTask({
|
||
required this.item,
|
||
required this.isBusy,
|
||
required this.onCheckIn,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hasItem = item.isNotEmpty;
|
||
final isCompleted = item['isCompleted'] == true;
|
||
final isRestDay = item['isRestDay'] == true;
|
||
final itemId = item['id']?.toString() ?? '';
|
||
final name = item['exerciseType']?.toString() ?? '今日休息';
|
||
final minutes = ((item['durationMinutes'] as num?)?.toInt() ?? 0);
|
||
final canCheckIn = hasItem && !isRestDay;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.fromLTRB(12, 11, 10, 11),
|
||
decoration: BoxDecoration(
|
||
color: isCompleted ? AppColors.successLight : const Color(0xFFF8FAFC),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: isCompleted
|
||
? AppTheme.success.withValues(alpha: 0.18)
|
||
: AppColors.borderLight,
|
||
),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
isCompleted ? Icons.check_circle : Icons.today_outlined,
|
||
size: 22,
|
||
color: isCompleted
|
||
? AppTheme.success
|
||
: _ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'今日任务',
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(
|
||
!hasItem
|
||
? '今天没有安排'
|
||
: (isRestDay ? '休息日' : '$name · $minutes 分钟'),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
SizedBox(
|
||
height: 38,
|
||
child: FilledButton.icon(
|
||
onPressed: canCheckIn && !isBusy ? () => onCheckIn(itemId) : null,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: isCompleted
|
||
? Colors.white
|
||
: _ExercisePlanPageState._exerciseVisual.color,
|
||
foregroundColor: isCompleted ? AppTheme.success : Colors.white,
|
||
disabledBackgroundColor: AppColors.cardInner,
|
||
disabledForegroundColor: AppColors.textHint,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
minimumSize: const Size(0, 38),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
side: BorderSide(
|
||
color: isCompleted
|
||
? AppTheme.success.withValues(alpha: 0.34)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
),
|
||
icon: isBusy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: AppColors.textHint,
|
||
),
|
||
)
|
||
: Icon(
|
||
isCompleted ? Icons.undo_rounded : Icons.check_rounded,
|
||
size: 17,
|
||
),
|
||
label: Text(
|
||
!hasItem || isRestDay ? '今日无' : (isCompleted ? '撤销' : '打卡'),
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExerciseCheckInSummary extends StatelessWidget {
|
||
final int planCount;
|
||
final int totalToday;
|
||
final int doneToday;
|
||
|
||
const _ExerciseCheckInSummary({
|
||
required this.planCount,
|
||
required this.totalToday,
|
||
required this.doneToday,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pending = totalToday - doneToday;
|
||
final progress = totalToday == 0 ? 0.0 : doneToday / totalToday;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border, width: 1.1),
|
||
boxShadow: [AppTheme.shadowLight],
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
const Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
'今日运动进度',
|
||
style: TextStyle(
|
||
fontSize: 19,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
SizedBox(height: 2),
|
||
Text(
|
||
'只允许打卡今天的运动任务',
|
||
style: TextStyle(fontSize: 16, color: AppTheme.textSub),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
Text(
|
||
'${(progress * 100).round()}%',
|
||
style: TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w900,
|
||
color: _ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 9,
|
||
value: progress,
|
||
backgroundColor: AppColors.cardInner,
|
||
valueColor: AlwaysStoppedAnimation<Color>(
|
||
_ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _ExerciseSummaryStat(
|
||
label: '待完成',
|
||
value: '$pending',
|
||
icon: Icons.schedule_outlined,
|
||
color: AppColors.warningText,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _ExerciseSummaryStat(
|
||
label: '已打卡',
|
||
value: '$doneToday',
|
||
icon: Icons.check_circle_outline,
|
||
color: AppColors.successText,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _ExerciseSummaryStat(
|
||
label: '计划数',
|
||
value: '$planCount',
|
||
icon: Icons.view_week_outlined,
|
||
color: _ExercisePlanPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExerciseSummaryStat extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final IconData icon;
|
||
final Color color;
|
||
|
||
const _ExerciseSummaryStat({
|
||
required this.label,
|
||
required this.value,
|
||
required this.icon,
|
||
required this.color,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
constraints: const BoxConstraints(minHeight: 58),
|
||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.08),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(color: color.withValues(alpha: 0.14)),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(icon, color: color, size: 17),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
value,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
Text(
|
||
label,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class ExercisePlanDetailPage extends ConsumerStatefulWidget {
|
||
final String id;
|
||
const ExercisePlanDetailPage({super.key, required this.id});
|
||
|
||
@override
|
||
ConsumerState<ExercisePlanDetailPage> createState() =>
|
||
_ExercisePlanDetailPageState();
|
||
}
|
||
|
||
class _ExercisePlanDetailPageState
|
||
extends ConsumerState<ExercisePlanDetailPage> {
|
||
Future<Map<String, dynamic>?>? _future;
|
||
final Set<String> _busyItems = {};
|
||
|
||
static const _exerciseVisual = AppModuleVisuals.exercise;
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_load();
|
||
}
|
||
|
||
void _load() {
|
||
setState(() {
|
||
_future = widget.id.isEmpty
|
||
? Future<Map<String, dynamic>?>.value(null)
|
||
: ref.read(exerciseServiceProvider).getPlanDetail(widget.id);
|
||
});
|
||
}
|
||
|
||
Future<void> _checkIn(String itemId) async {
|
||
if (itemId.isEmpty || _busyItems.contains(itemId)) return;
|
||
setState(() => _busyItems.add(itemId));
|
||
try {
|
||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||
ref.invalidate(currentExercisePlanProvider);
|
||
_load();
|
||
} catch (e) {
|
||
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
||
if (mounted) {
|
||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||
}
|
||
}
|
||
|
||
String _todayKey() {
|
||
final now = DateTime.now();
|
||
return '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
title: const Text('运动计划详情'),
|
||
),
|
||
body: AppFutureView<Map<String, dynamic>?>(
|
||
future: _future,
|
||
onRetry: _load,
|
||
errorTitle: '运动计划详情加载失败',
|
||
onData: (ctx, plan) {
|
||
if (plan == null) {
|
||
return _empty(context, '运动计划详情', '没有找到这条运动计划');
|
||
}
|
||
|
||
final items =
|
||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||
final total = items.length;
|
||
final done = items.where((it) => it['isCompleted'] == true).length;
|
||
final progress = total == 0 ? 0.0 : done / total;
|
||
final today = _todayKey();
|
||
final todayItem = items.firstWhere(
|
||
(it) => it['scheduledDate']?.toString() == today,
|
||
orElse: () => <String, dynamic>{},
|
||
);
|
||
final exerciseName = items.isNotEmpty
|
||
? (items.first['exerciseType']?.toString() ?? '运动')
|
||
: '运动';
|
||
final startDate = plan['startDate']?.toString() ?? '';
|
||
final endDate = plan['endDate']?.toString() ?? '';
|
||
|
||
return RefreshIndicator(
|
||
onRefresh: () async => _load(),
|
||
child: ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||
children: [
|
||
_ExerciseHeroCard(
|
||
exerciseName: exerciseName,
|
||
startDate: startDate,
|
||
endDate: endDate,
|
||
durationMinutes: items.isNotEmpty
|
||
? ((items.first['durationMinutes'] as num?)?.toInt() ?? 0)
|
||
: 0,
|
||
done: done,
|
||
total: total,
|
||
progress: progress,
|
||
),
|
||
const SizedBox(height: 12),
|
||
_TodayExerciseCard(
|
||
item: todayItem,
|
||
isBusy: _busyItems.contains(
|
||
todayItem['id']?.toString() ?? '',
|
||
),
|
||
onCheckIn: () => _checkIn(todayItem['id']?.toString() ?? ''),
|
||
),
|
||
const SizedBox(height: 16),
|
||
const Text(
|
||
'每日安排',
|
||
style: TextStyle(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (items.isEmpty)
|
||
_empty(context, '每日安排', '这条计划还没有安排内容')
|
||
else
|
||
...items.map(
|
||
(item) => _ExerciseDayTile(
|
||
date: item['scheduledDate']?.toString() ?? '',
|
||
name: item['exerciseType']?.toString() ?? '运动',
|
||
minutes:
|
||
((item['durationMinutes'] as num?)?.toInt() ?? 0),
|
||
isCompleted: item['isCompleted'] == true,
|
||
isToday: item['scheduledDate']?.toString() == today,
|
||
isRestDay: item['isRestDay'] == true,
|
||
isBusy: _busyItems.contains(item['id']?.toString() ?? ''),
|
||
onTap: item['scheduledDate']?.toString() == today
|
||
? () => _checkIn(item['id']?.toString() ?? '')
|
||
: null,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExerciseHeroCard extends StatelessWidget {
|
||
final String exerciseName;
|
||
final String startDate;
|
||
final String endDate;
|
||
final int durationMinutes;
|
||
final int done;
|
||
final int total;
|
||
final double progress;
|
||
|
||
const _ExerciseHeroCard({
|
||
required this.exerciseName,
|
||
required this.startDate,
|
||
required this.endDate,
|
||
required this.durationMinutes,
|
||
required this.done,
|
||
required this.total,
|
||
required this.progress,
|
||
});
|
||
|
||
String _slashDate(String value) {
|
||
if (value.length >= 10) {
|
||
return value.substring(0, 10).replaceAll('-', '/');
|
||
}
|
||
return value.replaceAll('-', '/');
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border),
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 42,
|
||
height: 42,
|
||
decoration: BoxDecoration(
|
||
gradient:
|
||
_ExercisePlanDetailPageState._exerciseVisual.gradient,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(
|
||
_ExercisePlanDetailPageState._exerciseVisual.icon,
|
||
color: Colors.white,
|
||
size: 24,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
exerciseName,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textPrimary,
|
||
fontSize: 20,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'${_slashDate(startDate)} 至 ${_slashDate(endDate)} · $durationMinutes分钟/天',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
color: AppColors.textSecondary,
|
||
fontSize: 13,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 5,
|
||
),
|
||
decoration: BoxDecoration(
|
||
color:
|
||
_ExercisePlanDetailPageState._exerciseVisual.lightColor,
|
||
borderRadius: BorderRadius.circular(999),
|
||
border: Border.all(
|
||
color: _ExercisePlanDetailPageState
|
||
._exerciseVisual
|
||
.borderColor,
|
||
),
|
||
),
|
||
child: Text(
|
||
'$done/$total 天',
|
||
style: TextStyle(
|
||
color: _ExercisePlanDetailPageState._exerciseVisual.color,
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w700,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
ClipRRect(
|
||
borderRadius: BorderRadius.circular(999),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 8,
|
||
value: progress.clamp(0.0, 1.0),
|
||
backgroundColor:
|
||
_ExercisePlanDetailPageState._exerciseVisual.lightColor,
|
||
valueColor: AlwaysStoppedAnimation<Color>(
|
||
_ExercisePlanDetailPageState._exerciseVisual.color,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _TodayExerciseCard extends StatelessWidget {
|
||
final Map<String, dynamic> item;
|
||
final bool isBusy;
|
||
final VoidCallback onCheckIn;
|
||
|
||
const _TodayExerciseCard({
|
||
required this.item,
|
||
required this.isBusy,
|
||
required this.onCheckIn,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final hasItem = item.isNotEmpty;
|
||
final isDone = item['isCompleted'] == true;
|
||
final isRestDay = item['isRestDay'] == true;
|
||
final name = item['exerciseType']?.toString() ?? '今日休息';
|
||
final minutes = ((item['durationMinutes'] as num?)?.toInt() ?? 0);
|
||
final canCheckIn = hasItem && !isRestDay;
|
||
|
||
return Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(16),
|
||
border: Border.all(color: AppColors.border, width: 1.1),
|
||
boxShadow: [AppTheme.shadowLight],
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: isDone
|
||
? AppColors.successLight
|
||
: _ExercisePlanDetailPageState._exerciseVisual.lightColor,
|
||
borderRadius: BorderRadius.circular(11),
|
||
),
|
||
child: Icon(
|
||
isDone ? Icons.check_circle : Icons.today_outlined,
|
||
color: isDone
|
||
? AppTheme.success
|
||
: _ExercisePlanDetailPageState._exerciseVisual.color,
|
||
size: 23,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
hasItem ? '今日任务' : '今日无任务',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
color: AppColors.textSecondary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
const SizedBox(height: 3),
|
||
Text(
|
||
hasItem ? '$name · $minutes 分钟' : '可以好好恢复,保持轻量活动',
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
if (hasItem)
|
||
SizedBox(
|
||
height: 38,
|
||
child: FilledButton.icon(
|
||
onPressed: canCheckIn && !isBusy ? onCheckIn : null,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: isDone
|
||
? Colors.white
|
||
: _ExercisePlanDetailPageState._exerciseVisual.color,
|
||
foregroundColor: isDone ? AppTheme.success : Colors.white,
|
||
disabledBackgroundColor: AppColors.cardInner,
|
||
disabledForegroundColor: AppColors.textHint,
|
||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||
minimumSize: const Size(0, 38),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
side: BorderSide(
|
||
color: isDone
|
||
? AppTheme.success.withValues(alpha: 0.34)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
),
|
||
icon: isBusy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: AppColors.textHint,
|
||
),
|
||
)
|
||
: Icon(
|
||
isDone ? Icons.undo_rounded : Icons.check_rounded,
|
||
size: 17,
|
||
),
|
||
label: Text(
|
||
isRestDay ? '休息' : (isDone ? '撤销' : '打卡'),
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ExerciseDayTile extends StatelessWidget {
|
||
final String date;
|
||
final String name;
|
||
final int minutes;
|
||
final bool isCompleted;
|
||
final bool isToday;
|
||
final bool isRestDay;
|
||
final bool isBusy;
|
||
final VoidCallback? onTap;
|
||
|
||
const _ExerciseDayTile({
|
||
required this.date,
|
||
required this.name,
|
||
required this.minutes,
|
||
required this.isCompleted,
|
||
required this.isToday,
|
||
required this.isRestDay,
|
||
required this.isBusy,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final canCheckIn = isToday && !isRestDay;
|
||
final subtitle = isRestDay ? '$date · 休息日' : '$date · $minutes 分钟';
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.only(bottom: 8),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(
|
||
color: isToday
|
||
? _ExercisePlanDetailPageState._exerciseVisual.color
|
||
: AppColors.borderLight,
|
||
width: isToday ? 1.3 : 1,
|
||
),
|
||
),
|
||
child: ListTile(
|
||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||
leading: Container(
|
||
width: 38,
|
||
height: 38,
|
||
decoration: BoxDecoration(
|
||
color: isCompleted
|
||
? AppColors.successLight
|
||
: _ExercisePlanDetailPageState._exerciseVisual.lightColor,
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(
|
||
isCompleted
|
||
? Icons.check
|
||
: _ExercisePlanDetailPageState._exerciseVisual.icon,
|
||
color: isCompleted
|
||
? AppTheme.success
|
||
: _ExercisePlanDetailPageState._exerciseVisual.color,
|
||
size: 21,
|
||
),
|
||
),
|
||
title: Text(
|
||
isToday ? '$name · 今天' : name,
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
|
||
),
|
||
subtitle: Text(
|
||
subtitle,
|
||
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||
),
|
||
trailing: SizedBox(
|
||
height: 36,
|
||
child: FilledButton.icon(
|
||
onPressed: canCheckIn && !isBusy ? onTap : null,
|
||
style: FilledButton.styleFrom(
|
||
backgroundColor: isCompleted
|
||
? Colors.white
|
||
: _ExercisePlanDetailPageState._exerciseVisual.color,
|
||
foregroundColor: isCompleted ? AppTheme.success : Colors.white,
|
||
disabledBackgroundColor: AppColors.cardInner,
|
||
disabledForegroundColor: AppColors.textHint,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
minimumSize: const Size(0, 36),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(10),
|
||
side: BorderSide(
|
||
color: isCompleted
|
||
? AppTheme.success.withValues(alpha: 0.34)
|
||
: Colors.transparent,
|
||
),
|
||
),
|
||
),
|
||
icon: isBusy
|
||
? const SizedBox(
|
||
width: 14,
|
||
height: 14,
|
||
child: CircularProgressIndicator(
|
||
strokeWidth: 2,
|
||
color: AppColors.textHint,
|
||
),
|
||
)
|
||
: Icon(
|
||
isCompleted ? Icons.undo_rounded : Icons.check_rounded,
|
||
size: 16,
|
||
),
|
||
label: Text(
|
||
!isToday
|
||
? '只读'
|
||
: (isRestDay ? '休息' : (isCompleted ? '撤销' : '打卡')),
|
||
style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w800),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 新建运动计划
|
||
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) {
|
||
AppToast.show(context, '请输入运动名称', type: AppToastType.warning);
|
||
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: AppSpacing.page,
|
||
children: [
|
||
Container(
|
||
padding: AppSpacing.panel,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: AppModuleVisuals.exercise.lightColor,
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Icon(
|
||
AppModuleVisuals.exercise.icon,
|
||
color: AppModuleVisuals.exercise.color,
|
||
size: 23,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
const Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('制定运动计划', style: AppTextStyles.sectionTitle),
|
||
SizedBox(height: 3),
|
||
Text('设置运动内容、周期和每日提醒', style: AppTextStyles.listSubtitle),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
padding: AppSpacing.panel,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_field('运动名称', _nameCtrl, hint: '如:散步、慢跑、太极'),
|
||
const SizedBox(height: 16),
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: _field(
|
||
'每天时长(分钟)',
|
||
_durationCtrl,
|
||
hint: '30',
|
||
number: true,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _field('坚持天数', _daysCtrl, hint: '7', number: true),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
|
||
const SizedBox(height: 16),
|
||
_timeField(),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 18),
|
||
AppGradientOutlineButton(label: '保存计划', onPressed: _save),
|
||
const SizedBox(height: 18),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _field(
|
||
String label,
|
||
TextEditingController ctrl, {
|
||
String? hint,
|
||
bool number = false,
|
||
}) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
TextField(
|
||
controller: ctrl,
|
||
keyboardType: number ? TextInputType.number : null,
|
||
decoration: InputDecoration(
|
||
hintText: hint,
|
||
filled: true,
|
||
fillColor: AppColors.cardInner,
|
||
border: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: const BorderSide(
|
||
color: AppColors.primary,
|
||
width: 1.5,
|
||
),
|
||
),
|
||
),
|
||
style: const TextStyle(fontSize: 16),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _dateField(
|
||
String label,
|
||
DateTime val,
|
||
ValueChanged<DateTime> onChanged,
|
||
) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(
|
||
onTap: () async {
|
||
final d = await showAppDatePicker(
|
||
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: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
_displayDate(val),
|
||
style: const TextStyle(fontSize: 16),
|
||
),
|
||
),
|
||
const Icon(
|
||
LucideIcons.calendarDays,
|
||
size: 19,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(
|
||
onTap: () async {
|
||
final picked = await showAppTimePicker(
|
||
context,
|
||
initialTime: _reminderTime,
|
||
);
|
||
if (picked != null && mounted) {
|
||
setState(() => _reminderTime = picked);
|
||
}
|
||
},
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(value, style: const TextStyle(fontSize: 16)),
|
||
),
|
||
const Icon(
|
||
LucideIcons.clock,
|
||
size: 19,
|
||
color: AppColors.primaryDark,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 复查列表(从服务器读取)
|
||
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: 10),
|
||
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')}';
|
||
}
|
||
}
|
||
|
||
/// 健康档案(可编辑)
|
||
List<String> normalizeArchiveItems(String input) {
|
||
final values = input
|
||
.split(RegExp(r'[、,,;;\n]'))
|
||
.map((value) => value.trim())
|
||
.where((value) => value.isNotEmpty)
|
||
.toSet()
|
||
.toList();
|
||
if (values.length > 1) values.remove('无');
|
||
return values;
|
||
}
|
||
|
||
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;
|
||
String? _loadError;
|
||
String? _surgeryHistoryStatus;
|
||
String? _updatedAt;
|
||
String _savedSignature = '';
|
||
int _nextSurgeryId = 0;
|
||
|
||
@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 results = await Future.wait([
|
||
srv.getProfile(),
|
||
srv.getHealthArchive(),
|
||
]);
|
||
final p = results[0];
|
||
final a = results[1];
|
||
if (!mounted) return;
|
||
if (p != null) {
|
||
_nameCtrl.text = p['name'] ?? '';
|
||
_genderCtrl.text = p['gender'] ?? '';
|
||
_birthDate = p['birthDate'] ?? '';
|
||
}
|
||
_surgeries.clear();
|
||
if (a != null) {
|
||
_diagnosisCtrl.text = a['diagnosis'] ?? '';
|
||
final surgeries = a['surgeries'] as List?;
|
||
if (surgeries != null && surgeries.isNotEmpty) {
|
||
_surgeries.addAll(
|
||
surgeries.whereType<Map>().map(
|
||
(item) => {
|
||
'localId': '${_nextSurgeryId++}',
|
||
'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({
|
||
'localId': '${_nextSurgeryId++}',
|
||
'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'] ?? '';
|
||
_surgeryHistoryStatus = a['surgeryHistoryStatus']?.toString();
|
||
_updatedAt = a['updatedAt']?.toString();
|
||
}
|
||
_surgeryHistoryStatus ??= _surgeries.isNotEmpty ? '有' : null;
|
||
_savedSignature = _formSignature();
|
||
setState(() {
|
||
_loading = false;
|
||
_loadError = null;
|
||
});
|
||
} catch (_) {
|
||
if (mounted) {
|
||
setState(() {
|
||
_loading = false;
|
||
_loadError = '健康档案加载失败';
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _pickDate(
|
||
void Function(String) onPicked, {
|
||
String current = '',
|
||
}) async {
|
||
final now = DateTime.now();
|
||
final parsedCurrent = DateTime.tryParse(current);
|
||
final d = await showAppDatePicker(
|
||
context,
|
||
initialDate: parsedCurrent ?? DateTime(now.year - 30),
|
||
firstDate: DateTime(1900),
|
||
lastDate: now,
|
||
);
|
||
if (d != null) {
|
||
onPicked(
|
||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}',
|
||
);
|
||
}
|
||
}
|
||
|
||
void _addSurgery() => setState(() {
|
||
_surgeryHistoryStatus = '有';
|
||
_surgeries.add({'localId': '${_nextSurgeryId++}', 'type': '', 'date': ''});
|
||
});
|
||
void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i));
|
||
void _setGender(String value) => setState(() => _genderCtrl.text = value);
|
||
|
||
void _setSurgeryHistoryStatus(String value) {
|
||
setState(() {
|
||
_surgeryHistoryStatus = value;
|
||
if (value == '无') _surgeries.clear();
|
||
if (value == '有' && _surgeries.isEmpty) {
|
||
_surgeries.add({
|
||
'localId': '${_nextSurgeryId++}',
|
||
'type': '',
|
||
'date': '',
|
||
});
|
||
}
|
||
});
|
||
}
|
||
|
||
int get _completionPercent {
|
||
final values = [
|
||
_nameCtrl.text.trim(),
|
||
_genderCtrl.text.trim(),
|
||
_birthDate,
|
||
_diagnosisCtrl.text.trim(),
|
||
_allergiesCtrl.text.trim(),
|
||
_chronicCtrl.text.trim(),
|
||
_dietCtrl.text.trim(),
|
||
_familyCtrl.text.trim(),
|
||
_surgeryHistoryStatus ?? '',
|
||
];
|
||
final completed = values.where((value) => value.isNotEmpty).length;
|
||
return (completed * 100 / values.length).round();
|
||
}
|
||
|
||
String _formSignature() => [
|
||
_nameCtrl.text.trim(),
|
||
_genderCtrl.text,
|
||
_birthDate,
|
||
_diagnosisCtrl.text.trim(),
|
||
_allergiesCtrl.text.trim(),
|
||
_chronicCtrl.text.trim(),
|
||
_dietCtrl.text.trim(),
|
||
_familyCtrl.text.trim(),
|
||
_surgeryHistoryStatus ?? '',
|
||
..._surgeries.expand(
|
||
(s) => [(s['type'] ?? '').trim(), (s['date'] ?? '').trim()],
|
||
),
|
||
].join('|');
|
||
|
||
bool get _hasUnsavedChanges => _formSignature() != _savedSignature;
|
||
|
||
Future<void> _save() async {
|
||
if (_nameCtrl.text.trim().isEmpty) {
|
||
AppToast.show(context, '请填写姓名', type: AppToastType.warning);
|
||
return;
|
||
}
|
||
if (_surgeryHistoryStatus == '有' &&
|
||
_surgeries.any((s) => (s['type'] ?? '').trim().isEmpty)) {
|
||
AppToast.show(context, '请填写手术名称', type: AppToastType.warning);
|
||
return;
|
||
}
|
||
setState(() => _saving = true);
|
||
var profileSaved = false;
|
||
try {
|
||
final srv = ref.read(userServiceProvider);
|
||
await srv.updateProfile(
|
||
name: _nameCtrl.text,
|
||
gender: _genderCtrl.text,
|
||
birthDate: _birthDate,
|
||
);
|
||
profileSaved = true;
|
||
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': normalizeArchiveItems(_allergiesCtrl.text),
|
||
'chronicDiseases': normalizeArchiveItems(_chronicCtrl.text),
|
||
'dietRestrictions': normalizeArchiveItems(_dietCtrl.text),
|
||
'familyHistory': _familyCtrl.text,
|
||
'surgeryHistoryStatus': _surgeryHistoryStatus,
|
||
});
|
||
await ref.read(authProvider.notifier).refreshProfile();
|
||
_savedSignature = _formSignature();
|
||
_updatedAt = DateTime.now().toIso8601String();
|
||
if (mounted) {
|
||
AppToast.show(context, '健康档案已保存', type: AppToastType.success);
|
||
setState(() {});
|
||
}
|
||
} catch (_) {
|
||
if (mounted) {
|
||
AppToast.show(
|
||
context,
|
||
profileSaved ? '个人资料已保存,健康信息保存失败' : '保存失败,请稍后重试',
|
||
type: AppToastType.error,
|
||
);
|
||
}
|
||
} finally {
|
||
if (mounted) setState(() => _saving = false);
|
||
}
|
||
}
|
||
|
||
Future<void> _handleBack() async {
|
||
if (!_hasUnsavedChanges) {
|
||
popRoute(ref);
|
||
return;
|
||
}
|
||
final leave = await showDialog<bool>(
|
||
context: context,
|
||
builder: (dialogContext) => AlertDialog(
|
||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
||
title: const Text('放弃修改?'),
|
||
content: const Text('当前健康档案还有未保存的修改。'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(dialogContext, false),
|
||
child: const Text('继续编辑'),
|
||
),
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(dialogContext, true),
|
||
child: const Text(
|
||
'放弃修改',
|
||
style: TextStyle(color: AppColors.errorText),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
if (leave == true && mounted) popRoute(ref);
|
||
}
|
||
|
||
@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: _handleBack,
|
||
),
|
||
),
|
||
body: const Center(child: CircularProgressIndicator()),
|
||
);
|
||
}
|
||
if (_loadError != null) {
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
title: const Text('健康档案'),
|
||
centerTitle: true,
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
),
|
||
body: AppErrorState(
|
||
title: _loadError!,
|
||
subtitle: '为避免覆盖原有档案,加载成功前不能编辑',
|
||
onRetry: () {
|
||
setState(() {
|
||
_loading = true;
|
||
_loadError = null;
|
||
});
|
||
_load();
|
||
},
|
||
),
|
||
);
|
||
}
|
||
return GradientScaffold(
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.white,
|
||
elevation: 0,
|
||
surfaceTintColor: Colors.transparent,
|
||
title: const Text('健康档案'),
|
||
centerTitle: true,
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
|
||
onPressed: _handleBack,
|
||
),
|
||
),
|
||
bottomNavigationBar: _ArchiveSaveBar(saving: _saving, onSave: _save),
|
||
body: ListView(
|
||
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||
children: [
|
||
_ArchiveSummary(
|
||
completion: _completionPercent,
|
||
updatedAt: _updatedAt,
|
||
),
|
||
const SizedBox(height: 16),
|
||
_Section('个人资料', LucideIcons.userRound, [
|
||
_F('姓名', _nameCtrl, hint: '请输入姓名'),
|
||
const SizedBox(height: 12),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
child: _GenderF(
|
||
value: _genderCtrl.text,
|
||
onChanged: _setGender,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: _DateF(
|
||
'出生日期',
|
||
_birthDate,
|
||
() => _pickDate(
|
||
(v) => setState(() => _birthDate = v),
|
||
current: _birthDate,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
_Section('医疗经历', LucideIcons.clipboardPlus, [
|
||
_F(
|
||
'主要诊断',
|
||
_diagnosisCtrl,
|
||
hint: '如:冠心病',
|
||
onFillNone: () => _diagnosisCtrl.text = '无',
|
||
),
|
||
const SizedBox(height: 14),
|
||
Row(
|
||
children: [
|
||
const Text(
|
||
'手术史',
|
||
style: TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 8),
|
||
_BinaryChoice(
|
||
value: _surgeryHistoryStatus,
|
||
leftLabel: '无手术史',
|
||
leftValue: '无',
|
||
rightLabel: '有手术史',
|
||
rightValue: '有',
|
||
onChanged: _setSurgeryHistoryStatus,
|
||
),
|
||
if (_surgeryHistoryStatus == '有') ...[
|
||
const SizedBox(height: 8),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: _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: '如: 心脏搭桥',
|
||
key: ValueKey(s['localId']),
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: _DateF2(
|
||
'日期',
|
||
s['date'] ?? '',
|
||
() => _pickDate(
|
||
(v) => setState(() => s['date'] = v),
|
||
current: s['date'] ?? '',
|
||
),
|
||
hint: '点击选择',
|
||
),
|
||
),
|
||
GestureDetector(
|
||
onTap: () => _removeSurgery(i),
|
||
child: Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: AppColors.errorLight,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: const Icon(
|
||
Icons.close,
|
||
size: 16,
|
||
color: AppColors.errorText,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}),
|
||
]),
|
||
const SizedBox(height: 16),
|
||
_Section('健康风险', LucideIcons.shieldAlert, [
|
||
_F(
|
||
'过敏史',
|
||
_allergiesCtrl,
|
||
hint: '多个项目用顿号分隔,如:青霉素、海鲜',
|
||
onFillNone: () => _allergiesCtrl.text = '无',
|
||
),
|
||
const SizedBox(height: 14),
|
||
_F(
|
||
'慢性病史',
|
||
_chronicCtrl,
|
||
hint: '多个项目用顿号分隔,如:高血压、高血脂',
|
||
onFillNone: () => _chronicCtrl.text = '无',
|
||
),
|
||
const SizedBox(height: 14),
|
||
_F(
|
||
'饮食限制',
|
||
_dietCtrl,
|
||
hint: '多个项目用顿号分隔,如:低盐、低脂',
|
||
onFillNone: () => _dietCtrl.text = '无',
|
||
),
|
||
const SizedBox(height: 14),
|
||
_F(
|
||
'家族病史',
|
||
_familyCtrl,
|
||
hint: '如:父亲患有冠心病',
|
||
onFillNone: () => _familyCtrl.text = '无',
|
||
),
|
||
]),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _ArchiveSummary extends StatelessWidget {
|
||
final int completion;
|
||
final String? updatedAt;
|
||
|
||
const _ArchiveSummary({required this.completion, required this.updatedAt});
|
||
|
||
String get _updatedLabel {
|
||
final date = DateTime.tryParse(updatedAt ?? '')?.toLocal();
|
||
if (date == null) return '尚未保存健康档案';
|
||
return '更新于 ${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
SizedBox(
|
||
width: 58,
|
||
height: 58,
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
CircularProgressIndicator(
|
||
value: completion / 100,
|
||
strokeWidth: 5,
|
||
color: AppColors.primary,
|
||
backgroundColor: AppColors.primaryLight,
|
||
),
|
||
Text(
|
||
'$completion%',
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('档案完整度', style: AppTextStyles.listTitle),
|
||
const SizedBox(height: 5),
|
||
Text(_updatedLabel, style: AppTextStyles.listSubtitle),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _ArchiveSaveBar extends StatelessWidget {
|
||
final bool saving;
|
||
final VoidCallback onSave;
|
||
|
||
const _ArchiveSaveBar({required this.saving, required this.onSave});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => SafeArea(
|
||
top: false,
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
|
||
color: Colors.white,
|
||
child: AppGradientOutlineButton(
|
||
label: '保存修改',
|
||
loading: saving,
|
||
onPressed: saving ? null : onSave,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _Section extends StatelessWidget {
|
||
final String title;
|
||
final IconData icon;
|
||
final List<Widget> fields;
|
||
const _Section(this.title, this.icon, this.fields);
|
||
Color get _color => AppColors.primary;
|
||
@override
|
||
Widget build(BuildContext c) => Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Container(
|
||
width: 28,
|
||
height: 28,
|
||
decoration: BoxDecoration(
|
||
color: _color.withValues(alpha: 0.10),
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Icon(icon, size: 17, color: _color),
|
||
),
|
||
const SizedBox(width: 9),
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 11),
|
||
...fields,
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _F extends StatelessWidget {
|
||
final String label;
|
||
final TextEditingController ctrl;
|
||
final String? hint;
|
||
final VoidCallback? onFillNone;
|
||
const _F(this.label, this.ctrl, {this.hint, this.onFillNone});
|
||
@override
|
||
Widget build(BuildContext c) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
if (onFillNone != null)
|
||
TextButton(
|
||
onPressed: onFillNone,
|
||
style: TextButton.styleFrom(
|
||
minimumSize: const Size(0, 30),
|
||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||
foregroundColor: AppColors.primary,
|
||
),
|
||
child: const Text('填无'),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 5),
|
||
TextField(
|
||
controller: ctrl,
|
||
style: const TextStyle(fontSize: 15),
|
||
decoration: InputDecoration(
|
||
hintText: hint,
|
||
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||
filled: true,
|
||
fillColor: AppColors.cardInner,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 14,
|
||
vertical: 11,
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: const BorderSide(color: AppColors.primary),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
class _GenderF extends StatelessWidget {
|
||
final String value;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _GenderF({required this.value, required this.onChanged});
|
||
|
||
@override
|
||
Widget build(BuildContext c) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text(
|
||
'性别',
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Container(
|
||
height: 44,
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(child: _GenderChip('男', value == '男', onChanged)),
|
||
const SizedBox(width: 4),
|
||
Expanded(child: _GenderChip('女', value == '女', onChanged)),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _BinaryChoice extends StatelessWidget {
|
||
final String? value;
|
||
final String leftLabel;
|
||
final String leftValue;
|
||
final String rightLabel;
|
||
final String rightValue;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _BinaryChoice({
|
||
required this.value,
|
||
required this.leftLabel,
|
||
required this.leftValue,
|
||
required this.rightLabel,
|
||
required this.rightValue,
|
||
required this.onChanged,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
height: 48,
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: _ChoiceItem(
|
||
label: leftLabel,
|
||
selected: value == leftValue,
|
||
onTap: () => onChanged(leftValue),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
Expanded(
|
||
child: _ChoiceItem(
|
||
label: rightLabel,
|
||
selected: value == rightValue,
|
||
onTap: () => onChanged(rightValue),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _ChoiceItem extends StatelessWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final VoidCallback onTap;
|
||
|
||
const _ChoiceItem({
|
||
required this.label,
|
||
required this.selected,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Material(
|
||
color: selected ? AppColors.primary : Colors.transparent,
|
||
borderRadius: AppRadius.smBorder,
|
||
child: InkWell(
|
||
onTap: onTap,
|
||
borderRadius: AppRadius.smBorder,
|
||
child: Center(
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: selected ? Colors.white : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
class _GenderChip extends StatelessWidget {
|
||
final String label;
|
||
final bool selected;
|
||
final ValueChanged<String> onChanged;
|
||
|
||
const _GenderChip(this.label, this.selected, this.onChanged);
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return GestureDetector(
|
||
onTap: () => onChanged(label),
|
||
child: Container(
|
||
alignment: Alignment.center,
|
||
decoration: BoxDecoration(
|
||
color: selected ? AppColors.primary : Colors.transparent,
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
fontWeight: FontWeight.w700,
|
||
color: selected ? Colors.white : AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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, super.key});
|
||
@override
|
||
Widget build(BuildContext c) => Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
|
||
),
|
||
const SizedBox(height: 4),
|
||
TextFormField(
|
||
initialValue: value,
|
||
onChanged: chg,
|
||
style: const TextStyle(fontSize: 14),
|
||
decoration: InputDecoration(
|
||
hintText: hint,
|
||
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||
filled: true,
|
||
fillColor: AppColors.cardInner,
|
||
contentPadding: const EdgeInsets.symmetric(
|
||
horizontal: 10,
|
||
vertical: 8,
|
||
),
|
||
border: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
enabledBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
borderSide: BorderSide.none,
|
||
),
|
||
focusedBorder: OutlineInputBorder(
|
||
borderRadius: AppRadius.mdBorder,
|
||
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,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
GestureDetector(
|
||
onTap: onTap,
|
||
child: Container(
|
||
height: 44,
|
||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||
decoration: BoxDecoration(
|
||
color: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
alignment: Alignment.centerLeft,
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
value.isEmpty ? '选择日期' : _slashDate(value),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 14,
|
||
color: value.isEmpty
|
||
? AppColors.textHint
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 4),
|
||
const Icon(
|
||
Icons.calendar_today_outlined,
|
||
size: 18,
|
||
color: Color(0xFF7C5CFF),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
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: AppColors.cardInner,
|
||
borderRadius: AppRadius.mdBorder,
|
||
),
|
||
alignment: Alignment.centerLeft,
|
||
child: Row(
|
||
children: [
|
||
Text(
|
||
value.isEmpty ? (hint ?? '点击选择') : _slashDate(value),
|
||
style: TextStyle(
|
||
fontSize: 13,
|
||
color: value.isEmpty
|
||
? AppColors.textHint
|
||
: AppColors.textPrimary,
|
||
),
|
||
),
|
||
const Spacer(),
|
||
const Icon(
|
||
Icons.calendar_today_outlined,
|
||
size: 14,
|
||
color: Color(0xFF7C5CFF),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
String _slashDate(String value) {
|
||
final date = value.trim();
|
||
if (date.length >= 10) return date.substring(0, 10);
|
||
return date;
|
||
}
|
||
|
||
String _displayDate(DateTime date) {
|
||
return '${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
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: AppRadius.mdBorder,
|
||
border: Border.all(color: AppColors.primaryLight),
|
||
),
|
||
child: const Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.add, size: 14, color: AppColors.primaryDark),
|
||
SizedBox(width: 2),
|
||
Text(
|
||
'添加',
|
||
style: TextStyle(fontSize: 13, color: AppColors.primaryDark),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 健康日历
|
||
DateTime calendarDateForMonth(DateTime month, DateTime selectedDate) {
|
||
final lastDay = DateTime(month.year, month.month + 1, 0).day;
|
||
final day = selectedDate.day > lastDay ? lastDay : selectedDate.day;
|
||
return DateTime(month.year, month.month, day);
|
||
}
|
||
|
||
class HealthCalendarPage extends ConsumerStatefulWidget {
|
||
const HealthCalendarPage({super.key});
|
||
@override
|
||
ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
|
||
}
|
||
|
||
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||
DateTime _currentMonth = DateTime.now();
|
||
DateTime? _selectedDate = DateTime.now();
|
||
Map<String, List<Map<String, dynamic>>> _events = {};
|
||
Map<String, dynamic>? _selectedDayData;
|
||
final Map<String, Map<String, dynamic>> _dayCache = {};
|
||
bool _loadingMonth = true;
|
||
bool _loadingDay = false;
|
||
String? _monthError;
|
||
String? _dayError;
|
||
int _monthRequestId = 0;
|
||
int _dayRequestId = 0;
|
||
|
||
static const _medColor = AppColors.medication;
|
||
static const _exerciseColor = AppColors.exercise;
|
||
static const _followupColor = AppColors.followup;
|
||
static const _calendarColor = AppColors.calendar;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_loadMonth();
|
||
}
|
||
|
||
String _dateKey(DateTime d) =>
|
||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||
|
||
Future<void> _loadMonth() async {
|
||
final requestId = ++_monthRequestId;
|
||
final requestedMonth = DateTime(_currentMonth.year, _currentMonth.month);
|
||
setState(() {
|
||
_loadingMonth = true;
|
||
_monthError = null;
|
||
_events = {};
|
||
});
|
||
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((type) => {'type': type})
|
||
.toList();
|
||
}
|
||
if (!mounted ||
|
||
requestId != _monthRequestId ||
|
||
requestedMonth.year != _currentMonth.year ||
|
||
requestedMonth.month != _currentMonth.month) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_events = events;
|
||
_loadingMonth = false;
|
||
});
|
||
final selectedDate = _selectedDate;
|
||
if (selectedDate != null) _selectDate(selectedDate, force: true);
|
||
} catch (_) {
|
||
if (!mounted || requestId != _monthRequestId) return;
|
||
setState(() {
|
||
_loadingMonth = false;
|
||
_monthError = '本月计划加载失败';
|
||
_loadingDay = false;
|
||
});
|
||
}
|
||
}
|
||
|
||
void _selectDate(DateTime d, {bool force = false}) {
|
||
final key = _dateKey(d);
|
||
final cached = _dayCache[key];
|
||
setState(() {
|
||
_selectedDate = d;
|
||
_selectedDayData = cached;
|
||
_dayError = null;
|
||
_loadingDay = false;
|
||
});
|
||
if (cached != null && !force) return;
|
||
final details = _events[key];
|
||
if (details != null && details.isNotEmpty) {
|
||
_loadDayDetail(d);
|
||
} else {
|
||
setState(() => _selectedDayData = _emptyDayData());
|
||
}
|
||
}
|
||
|
||
Map<String, dynamic> _emptyDayData() => {
|
||
'medications': <Map<String, dynamic>>[],
|
||
'exercises': <Map<String, dynamic>>[],
|
||
'followUps': <Map<String, dynamic>>[],
|
||
};
|
||
|
||
Future<void> _loadDayDetail(DateTime d) async {
|
||
final requestId = ++_dayRequestId;
|
||
final key = _dateKey(d);
|
||
setState(() {
|
||
_loadingDay = true;
|
||
_dayError = null;
|
||
_selectedDayData = null;
|
||
});
|
||
try {
|
||
final api = ref.read(apiClientProvider);
|
||
final res = await api.get(
|
||
'/api/calendar/day',
|
||
queryParameters: {'date': _dateKey(d)},
|
||
);
|
||
if (!mounted ||
|
||
requestId != _dayRequestId ||
|
||
_selectedDate == null ||
|
||
_dateKey(_selectedDate!) != key) {
|
||
return;
|
||
}
|
||
final data = res.data['data'] as Map<String, dynamic>? ?? _emptyDayData();
|
||
_dayCache[key] = data;
|
||
setState(() {
|
||
_selectedDayData = data;
|
||
_loadingDay = false;
|
||
});
|
||
} catch (_) {
|
||
if (!mounted ||
|
||
requestId != _dayRequestId ||
|
||
_selectedDate == null ||
|
||
_dateKey(_selectedDate!) != key) {
|
||
return;
|
||
}
|
||
setState(() {
|
||
_loadingDay = false;
|
||
_dayError = '当天计划加载失败';
|
||
});
|
||
}
|
||
}
|
||
|
||
void _changeMonth(int offset) {
|
||
final nextMonth = DateTime(
|
||
_currentMonth.year,
|
||
_currentMonth.month + offset,
|
||
);
|
||
final selectedDate = _selectedDate ?? _currentMonth;
|
||
final nextDate = calendarDateForMonth(nextMonth, selectedDate);
|
||
_dayRequestId++;
|
||
setState(() {
|
||
_currentMonth = nextMonth;
|
||
_selectedDate = nextDate;
|
||
_selectedDayData = null;
|
||
_dayError = null;
|
||
_loadingDay = false;
|
||
});
|
||
_loadMonth();
|
||
}
|
||
|
||
@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: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||
child: Container(
|
||
padding: const EdgeInsets.fromLTRB(10, 8, 10, 12),
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.xlBorder,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
_buildMonthHeader(),
|
||
_buildWeekdayHeader(),
|
||
const SizedBox(height: 6),
|
||
_buildCalendarGrid(),
|
||
if (_loadingMonth)
|
||
const Padding(
|
||
padding: EdgeInsets.fromLTRB(8, 8, 8, 0),
|
||
child: LinearProgressIndicator(
|
||
minHeight: 2,
|
||
color: _calendarColor,
|
||
backgroundColor: AppColors.calendarLight,
|
||
),
|
||
),
|
||
if (_monthError != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 6),
|
||
child: TextButton.icon(
|
||
onPressed: _loadMonth,
|
||
icon: const Icon(Icons.refresh, size: 17),
|
||
label: Text('$_monthError,点击重试'),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
// 图例
|
||
Padding(
|
||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||
child: Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||
children: [
|
||
_Legend('用药提醒', _medColor),
|
||
_Legend('运动计划', _exerciseColor),
|
||
_Legend('复查随访', _followupColor),
|
||
],
|
||
),
|
||
),
|
||
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),
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: const Color(0xFFF8FAFC),
|
||
foregroundColor: _calendarColor,
|
||
),
|
||
onPressed: () {
|
||
_changeMonth(-1);
|
||
},
|
||
),
|
||
Text(
|
||
'${_currentMonth.year}年${_currentMonth.month}月',
|
||
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||
),
|
||
IconButton(
|
||
icon: const Icon(Icons.chevron_right),
|
||
style: IconButton.styleFrom(
|
||
backgroundColor: const Color(0xFFF8FAFC),
|
||
foregroundColor: _calendarColor,
|
||
),
|
||
onPressed: () {
|
||
_changeMonth(1);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
|
||
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
|
||
? _calendarColor
|
||
: (isToday
|
||
? _calendarColor.withValues(alpha: 0.08)
|
||
: Colors.white),
|
||
borderRadius: AppRadius.mdBorder,
|
||
border: isSel
|
||
? Border.all(color: _calendarColor, width: 1.5)
|
||
: (isToday
|
||
? Border.all(
|
||
color: _calendarColor.withValues(alpha: 0.24),
|
||
width: 1,
|
||
)
|
||
: null),
|
||
),
|
||
child: Stack(
|
||
alignment: Alignment.center,
|
||
children: [
|
||
Text(
|
||
'$day',
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
color: isSel ? Colors.white : AppColors.textPrimary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
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' => _medColor,
|
||
'exercise' => _exerciseColor,
|
||
'followup' => _followupColor,
|
||
'health' => AppColors.health,
|
||
'diet' => DietPalette.primary,
|
||
'report' => AppColors.report,
|
||
'device' => AppColors.device,
|
||
_ => AppColors.textHint,
|
||
};
|
||
|
||
Widget _buildDayDetail() {
|
||
if (_selectedDate == null) {
|
||
return const Center(
|
||
child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)),
|
||
);
|
||
}
|
||
if (_loadingDay) {
|
||
return const Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
SizedBox(
|
||
width: 26,
|
||
height: 26,
|
||
child: CircularProgressIndicator(strokeWidth: 2.5),
|
||
),
|
||
SizedBox(height: 12),
|
||
Text('正在加载当天计划', style: TextStyle(color: AppColors.textSecondary)),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
if (_dayError != null) {
|
||
return AppErrorState(
|
||
title: _dayError!,
|
||
subtitle: '请检查网络后重新加载',
|
||
onRetry: () => _loadDayDetail(_selectedDate!),
|
||
);
|
||
}
|
||
|
||
final data = _selectedDayData;
|
||
if (data == null) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
|
||
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 AppEmptyState(
|
||
icon: AppModuleVisuals.calendar.icon,
|
||
iconColor: AppModuleVisuals.calendar.color,
|
||
title: '${_selectedDate!.month}月${_selectedDate!.day}日没有健康安排',
|
||
subtitle: '当天的用药、运动和复查计划会显示在这里',
|
||
);
|
||
}
|
||
|
||
return ListView(
|
||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 24),
|
||
children: [
|
||
Text(
|
||
'${_selectedDate!.month}月${_selectedDate!.day}日计划',
|
||
style: AppTextStyles.sectionTitle,
|
||
),
|
||
const SizedBox(height: 10),
|
||
if (meds.isNotEmpty) ...[
|
||
_PlanGroup(
|
||
title: '用药提醒',
|
||
color: _medColor,
|
||
icon: AppModuleVisuals.medication.icon,
|
||
items: meds
|
||
.map((m) => _PlanItem(title: '${m['name'] ?? '未命名药品'}'))
|
||
.toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
if (exs.isNotEmpty) ...[
|
||
_PlanGroup(
|
||
title: '运动计划',
|
||
color: _exerciseColor,
|
||
icon: AppModuleVisuals.exercise.icon,
|
||
items: exs
|
||
.map(
|
||
(e) => _PlanItem(
|
||
title: '${e['type'] ?? '运动计划'}',
|
||
trailing: '${e['duration'] ?? 0}分钟',
|
||
),
|
||
)
|
||
.toList(),
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
if (fups.isNotEmpty) ...[
|
||
_PlanGroup(
|
||
title: '复查随访',
|
||
color: _followupColor,
|
||
icon: AppModuleVisuals.followup.icon,
|
||
items: fups.map((f) {
|
||
final doctor = '${f['doctorName'] ?? ''}'.trim();
|
||
final department = '${f['department'] ?? ''}'.trim();
|
||
final detail = [
|
||
doctor,
|
||
department,
|
||
].where((value) => value.isNotEmpty).join(' · ');
|
||
return _PlanItem(
|
||
title: '${f['title'] ?? '复查随访'}',
|
||
trailing: detail,
|
||
);
|
||
}).toList(),
|
||
),
|
||
],
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
class _PlanItem {
|
||
final String title;
|
||
final String trailing;
|
||
|
||
const _PlanItem({required this.title, this.trailing = ''});
|
||
}
|
||
|
||
class _PlanGroup extends StatelessWidget {
|
||
final String title;
|
||
final Color color;
|
||
final IconData icon;
|
||
final List<_PlanItem> items;
|
||
|
||
const _PlanGroup({
|
||
required this.title,
|
||
required this.color,
|
||
required this.icon,
|
||
required this.items,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Container(
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: AppRadius.lgBorder,
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(14, 13, 14, 8),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 32,
|
||
height: 32,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.11),
|
||
borderRadius: AppRadius.smBorder,
|
||
),
|
||
child: Icon(icon, size: 18, color: color),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Text(
|
||
title,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w700,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
for (var index = 0; index < items.length; index++)
|
||
_PlanRow(
|
||
item: items[index],
|
||
color: color,
|
||
showDivider: index < items.length - 1,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
class _PlanRow extends StatelessWidget {
|
||
final _PlanItem item;
|
||
final Color color;
|
||
final bool showDivider;
|
||
|
||
const _PlanRow({
|
||
required this.item,
|
||
required this.color,
|
||
required this.showDivider,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Padding(
|
||
padding: const EdgeInsets.only(left: 14),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 8,
|
||
height: 8,
|
||
decoration: BoxDecoration(
|
||
color: color.withValues(alpha: 0.78),
|
||
shape: BoxShape.circle,
|
||
),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Container(
|
||
constraints: const BoxConstraints(minHeight: 48),
|
||
decoration: BoxDecoration(
|
||
border: showDivider
|
||
? const Border(
|
||
bottom: BorderSide(color: AppColors.divider, width: 0.7),
|
||
)
|
||
: null,
|
||
),
|
||
padding: const EdgeInsets.fromLTRB(0, 12, 14, 12),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
item.title,
|
||
style: const TextStyle(
|
||
fontSize: 15,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
),
|
||
),
|
||
if (item.trailing.isNotEmpty) ...[
|
||
const SizedBox(width: 12),
|
||
Flexible(
|
||
child: Text(
|
||
item.trailing,
|
||
textAlign: TextAlign.right,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
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: 6),
|
||
Text(
|
||
label,
|
||
style: const TextStyle(
|
||
fontSize: 13,
|
||
color: AppColors.textSecondary,
|
||
fontWeight: FontWeight.w600,
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
const Map<String, String> _extraStaticTextTitles = {
|
||
'personalInfoList': '个人信息收集清单',
|
||
'thirdPartySdkList': '第三方 SDK 共享清单',
|
||
};
|
||
|
||
const Map<String, String> _extraStaticTextContents = {
|
||
'personalInfoList': '''## 个人信息收集清单
|
||
|
||
更新日期:2026年7月9日
|
||
|
||
本清单用于说明小脉健康 App 在当前功能范围内可能收集的个人信息类型、使用目的和使用场景。实际启用情况以正式部署配置和您实际使用的功能为准。
|
||
|
||
### 一、账号与登录信息
|
||
|
||
- 信息内容:手机号、登录状态、账号标识、验证码校验结果。
|
||
- 使用目的:完成注册、登录、身份识别、账号安全校验和账号注销。
|
||
- 使用场景:登录、注册、刷新登录状态、删除账号。
|
||
|
||
### 二、个人资料和健康档案
|
||
|
||
- 信息内容:姓名或昵称、性别、出生日期、头像、过敏史、慢病史、家族史、手术信息、饮食禁忌等。
|
||
- 使用目的:建立健康档案,辅助 AI 健康咨询、医生咨询、报告解读和健康管理。
|
||
- 使用场景:完善资料、健康档案、AI 对话、医生端查看。
|
||
|
||
### 三、健康数据
|
||
|
||
- 信息内容:血压、心率、血糖、血氧、体重、记录时间、数据来源、异常状态。
|
||
- 使用目的:记录和展示健康趋势,生成健康提醒,辅助 AI 分析和医生咨询。
|
||
- 使用场景:手动录入、蓝牙血压计同步、健康概览、通知提醒、AI 对话确认录入。
|
||
|
||
### 四、用药、运动、饮食和日历数据
|
||
|
||
- 信息内容:药品名称、剂量、频次、服药时间、打卡记录、运动计划、饮食记录、热量估算、日程提醒。
|
||
- 使用目的:提供用药管理、运动计划、饮食识别、健康提醒和日历管理。
|
||
- 使用场景:用药管理、服药打卡、运动计划、饮食拍照识别、通知中心。
|
||
|
||
### 五、报告、图片和文件信息
|
||
|
||
- 信息内容:您主动上传的检查报告图片、聊天图片、PDF 文件、AI 识别结果、报告摘要和审核状态。
|
||
- 使用目的:完成报告管理、报告解读、图片识别、AI 对话附件分析。
|
||
- 使用场景:上传报告、拍照识别、AI 聊天发送照片或 PDF。
|
||
|
||
### 六、蓝牙设备信息
|
||
|
||
- 信息内容:设备名称、设备标识、设备类型、服务 UUID、最近同步时间、短期去重记录。
|
||
- 使用目的:发现、绑定和同步支持的健康设备。
|
||
- 使用场景:蓝牙设备扫描、血压计绑定、血压数据同步。
|
||
|
||
### 七、设备、日志和网络信息
|
||
|
||
- 信息内容:设备型号、系统版本、App 版本、接口请求状态、错误信息、网络状态。
|
||
- 使用目的:功能适配、问题排查、服务安全、性能优化和合规审计。
|
||
- 使用场景:App 使用过程中的接口访问、异常处理、故障排查。
|
||
''',
|
||
'thirdPartySdkList': '''## 第三方 SDK 共享清单
|
||
|
||
更新日期:2026年7月9日
|
||
|
||
本清单用于说明小脉健康 App 当前可能接入或依赖的第三方 SDK、外部接口和服务能力。正式上线前会根据实际启用的服务商、域名和资质信息继续更新。
|
||
|
||
### 一、AI 大模型和视觉识别服务
|
||
|
||
- 使用目的:AI 健康咨询、报告解读、图片识别、文本生成和内容理解。
|
||
- 可能共享的信息:您主动输入的对话内容、上传的图片或 PDF、健康档案摘要、需要 AI 分析的健康数据。
|
||
- 使用场景:AI 对话、拍照识别、上传报告、报告解读。
|
||
- 说明:实际服务商和接口地址以正式环境配置为准。
|
||
|
||
### 二、短信验证服务
|
||
|
||
- 使用目的:发送登录或注册验证码。
|
||
- 可能共享的信息:手机号、验证码发送状态、必要的风控信息。
|
||
- 使用场景:手机号登录、注册、身份验证。
|
||
- 说明:当前正式短信服务仍待接入,正式启用后补充服务商信息。
|
||
|
||
### 三、文件存储或服务器存储服务
|
||
|
||
- 使用目的:保存您主动上传的报告、图片和 PDF 文件。
|
||
- 可能共享的信息:文件内容、文件名称、上传时间、账号标识。
|
||
- 使用场景:报告管理、AI 聊天附件、图片识别。
|
||
- 说明:当前仍处于本地和开发环境阶段,正式服务器和域名确定后补充公网信息。
|
||
|
||
### 四、实时通信服务
|
||
|
||
- 使用目的:支持医生咨询消息、AI 流式回复和实时状态更新。
|
||
- 可能共享的信息:消息内容、会话标识、发送时间、账号标识。
|
||
- 使用场景:AI 对话、医生咨询、通知状态更新。
|
||
|
||
### 五、系统能力和开源组件
|
||
|
||
- 使用目的:蓝牙扫描、相机/相册选择、文件选择、图表展示、Markdown 渲染、网络请求等 App 基础能力。
|
||
- 可能涉及的信息:蓝牙设备信息、您主动选择的图片或文件、网络请求状态。
|
||
- 使用场景:蓝牙设备、上传照片、上传 PDF、健康趋势、合规文本展示。
|
||
|
||
我们不会向第三方出售您的个人信息。涉及个人信息处理的第三方服务,会在实现相关功能所必需的范围内使用,并在正式上线前结合实际服务商完善披露信息。
|
||
''',
|
||
};
|
||
|
||
String staticTextTitle(String type) => _extraStaticTextTitles[type] ?? '';
|
||
|
||
String staticTextContent(String type) => _extraStaticTextContents[type] ?? '';
|
||
|
||
/// 静态文本页
|
||
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': '关于小脉健康',
|
||
..._extraStaticTextTitles,
|
||
};
|
||
final contents = {
|
||
'privacy': '''## 小脉健康隐私政策
|
||
|
||
更新日期:2026年6月29日
|
||
生效日期:2026年6月29日
|
||
|
||
小脉健康重视您的个人信息和健康数据保护。本政策说明我们在您使用小脉健康 App 及相关服务时,如何收集、使用、存储、共享和保护您的信息,以及您如何管理自己的信息。
|
||
|
||
请您在注册、登录和使用本应用前仔细阅读本政策。若您不同意本政策内容,请停止注册或使用相关服务。
|
||
|
||
### 一、我们收集的信息
|
||
|
||
为向您提供健康管理、AI 健康咨询、报告解读、饮食识别、用药管理、运动计划、在线医生咨询和蓝牙设备同步等功能,我们会根据您使用的具体功能收集以下信息:
|
||
|
||
1. 账号与身份信息
|
||
- 手机号码:用于注册、登录、身份识别和账号安全验证。
|
||
- 姓名或昵称、性别、出生日期、头像:用于完善个人资料和展示账号信息。
|
||
- 医生选择或签约关系:用于向您提供医生咨询、报告审核和随访相关服务。
|
||
|
||
2. 健康档案与健康记录
|
||
- 健康档案:诊断信息、手术类型、手术日期、过敏史、饮食禁忌、慢性病史、家族史等您主动填写的信息。
|
||
- 健康指标:血压、心率、血糖、血氧、体重、记录时间、记录来源等。
|
||
- 用药信息:药品名称、剂量、频次、服药时间、提醒设置、服药打卡记录等。
|
||
- 饮食记录:餐次、食物名称、份量、热量估算、饮食评分等。
|
||
- 运动计划:运动项目、计划内容、完成情况等。
|
||
- 检查报告:您上传的报告图片、报告文件、AI 识别出的指标、摘要、医生审核意见等。
|
||
- 在线咨询和随访信息:您与医生或系统产生的咨询内容、咨询状态、随访内容和时间。
|
||
|
||
3. 图片、相机、相册和文件信息
|
||
- 当您使用“拍照上传报告”“拍照识别饮食”“聊天中发送图片”等功能时,我们会调用设备相机,用于拍摄检查报告、饮食图片或您希望 AI 辅助查看的图片。
|
||
- 当您使用“从相册选择”“上传 PDF”等功能时,我们会读取您主动选择的图片或 PDF 文件,用于报告管理、饮食识别、AI 对话附件解析等功能。
|
||
- 未经您主动选择或确认,我们不会读取您的相册其他内容,也不会持续访问您的相机。
|
||
|
||
4. 蓝牙设备信息
|
||
- 当您使用蓝牙设备功能时,我们会扫描和连接附近支持的健康设备。目前代码中已实现血压计数据同步,设备模型中预留了血糖仪、体重秤、血氧仪类型,但当前实际支持的数据解析以血压计为主。
|
||
- 我们会在本地保存已绑定设备的设备标识、设备名称、设备类型、服务 UUID、最近同步时间,以及用于避免重复同步的短期记录指纹。
|
||
- 通过血压计同步时,我们会读取并记录收缩压、舒张压、脉搏、测量时间等数据。
|
||
|
||
5. 位置信息相关说明
|
||
- Android 系统在部分版本中要求 App 申请定位权限后才允许进行蓝牙低功耗设备扫描。我们申请定位权限的目的,是用于发现和连接蓝牙健康设备。
|
||
- 当前代码未将您的定位经纬度上传至服务器,也未用于地图、轨迹、广告或位置画像。
|
||
|
||
6. 设备、日志和网络信息
|
||
- 设备型号、操作系统版本、App 版本、网络请求状态、接口错误信息等,用于功能适配、问题排查、服务安全和性能优化。
|
||
- 本应用会使用网络请求、流式消息、实时通信等能力,以便完成登录、AI 回复、医生咨询、数据同步等功能。
|
||
|
||
### 二、我们如何使用这些信息
|
||
|
||
我们会将收集的信息用于以下目的:
|
||
|
||
- 为您创建和维护账号,完成登录、退出登录、令牌刷新和账号安全验证。
|
||
- 记录和展示您的健康指标、用药、饮食、运动、报告、咨询和随访信息。
|
||
- 根据您输入或上传的信息,提供 AI 健康解释、报告预解读、饮食识别、用药和运动建议。
|
||
- 通过蓝牙连接健康设备并同步血压等测量数据。
|
||
- 向医生端展示与您咨询、签约、报告审核或随访相关的必要信息。
|
||
- 发送用药、运动、健康指标、报告处理等站内提醒。
|
||
- 进行系统维护、故障排查、安全审计、服务优化和合规管理。
|
||
|
||
本应用提供的 AI 分析、健康提醒和报告解读仅作为健康管理参考,不构成诊断、治疗或处方建议。如您出现胸痛、呼吸困难、意识异常、严重血压或血糖异常等紧急情况,请立即联系医生或前往医疗机构就诊。
|
||
|
||
### 三、权限调用说明
|
||
|
||
1. 相机权限
|
||
- 使用场景:拍摄检查报告、饮食图片、聊天附件图片。
|
||
- 使用目的:上传报告进行 AI 结构化解读;识别食物种类和估算份量、热量;在 AI 对话中让系统理解您主动发送的图片内容。
|
||
|
||
2. 相册/图片读取权限
|
||
- 使用场景:从相册选择报告图片、饮食图片或聊天图片。
|
||
- 使用目的:上传您主动选择的图片并完成报告管理、饮食识别或 AI 对话附件解析。
|
||
|
||
3. 文件读取权限
|
||
- 使用场景:在聊天中选择 PDF 文件。
|
||
- 使用目的:上传您主动选择的 PDF,供系统提取文字或生成 AI 对话上下文。
|
||
|
||
4. 蓝牙权限
|
||
- 使用场景:扫描、绑定和连接蓝牙健康设备。
|
||
- 使用目的:发现支持的血压计等健康设备,并同步血压、脉搏、测量时间等数据。
|
||
|
||
5. 定位权限
|
||
- 使用场景:Android 蓝牙低功耗扫描。
|
||
- 使用目的:满足系统蓝牙扫描要求。我们不会收集或上传您的精确定位经纬度。
|
||
|
||
### 四、信息上传和第三方服务
|
||
|
||
在您主动使用相关功能时,以下数据可能会上传至服务器:
|
||
|
||
- 账号信息、个人资料、健康档案和健康指标。
|
||
- 用药计划、服药记录、饮食记录、运动计划、日历和提醒数据。
|
||
- 检查报告图片、聊天图片、PDF 文件及其 AI 解析结果。
|
||
- 饮食图片识别结果、报告 AI 解读结果、AI 对话内容和医生咨询内容。
|
||
- 蓝牙血压计同步得到的血压、脉搏和测量时间。
|
||
|
||
为实现 AI 对话、图片识别、报告解读、知识库检索、短信验证和实时咨询等功能,我们可能接入以下第三方或外部服务。实际启用情况以正式部署配置为准:
|
||
|
||
- 大语言模型服务:用于 AI 健康咨询、报告预解读、文本生成和内容理解。
|
||
- 视觉模型服务:用于饮食图片识别、报告图片或聊天图片内容识别。
|
||
- 知识库检索服务:用于在 AI 回复时检索医学知识库或业务知识库资料。
|
||
- 短信服务:用于向您的手机号发送登录或注册验证码。
|
||
- 对象存储或服务器文件存储服务:用于保存您主动上传的报告、图片或 PDF 文件。
|
||
- 实时通信服务:用于医生咨询消息的实时收发。
|
||
|
||
我们不会向第三方出售您的个人信息或健康数据。若第三方服务处理您的信息,我们会尽力要求其仅在实现相关功能所必需的范围内处理,并采取合理的安全保护措施。
|
||
|
||
### 五、信息存储和保存期限
|
||
|
||
- 您的账号资料、健康档案、健康记录、用药、饮食、运动、报告、咨询、随访、通知和 AI 对话等业务数据,会保存至服务器数据库,用于持续向您提供服务。
|
||
- 您主动上传的检查报告图片会保存于服务器文件目录,并与报告记录关联;删除单份报告时,系统会删除对应报告记录和原始报告文件。
|
||
- 您在聊天中上传的图片或 PDF 会保存于服务器文件目录,用于 AI 附件解析和会话上下文展示。
|
||
- 饮食识别图片会临时保存至服务器用于识别处理,并提交视觉模型进行分析;识别结果会用于生成饮食记录。
|
||
- App 本地仅保存登录 token、偏好设置、已绑定蓝牙设备信息、最近同步信息和短期去重指纹等缓存数据,不作为健康业务数据的唯一来源。
|
||
- 我们将在实现服务目的所需期限内保存您的信息;法律法规另有要求的,从其规定。
|
||
|
||
### 六、您如何管理个人信息
|
||
|
||
您可以在 App 内查看、修改或删除部分信息:
|
||
|
||
- 在个人资料、健康档案、健康数据、用药、饮食、运动、报告、AI 会话等页面查看或管理相关记录。
|
||
- 在设置页查看《隐私政策》和《服务协议》。
|
||
- 在设置页使用“删除账号”功能申请注销账号。
|
||
|
||
账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。
|
||
|
||
请注意:如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。
|
||
|
||
### 七、我们如何保护信息安全
|
||
|
||
- 使用登录认证和访问控制保护您的账号和接口。
|
||
- 通过 HTTPS 等方式保护数据传输安全,正式上线时应使用有效的 HTTPS 证书。
|
||
- 对服务器、数据库和文件存储采取权限控制、日志审计、备份和安全配置。
|
||
- 医生端、管理端仅能访问其职责范围内必要的数据。
|
||
- 如发生个人信息安全事件,我们将按照法律法规要求及时采取补救措施并履行通知义务。
|
||
|
||
### 八、未成年人保护
|
||
|
||
本应用主要面向具备完全民事行为能力的成人用户。如未成年人需要使用本应用,应在监护人同意和指导下使用。监护人发现未成年人信息被不当收集或使用的,可通过本政策中的联系方式与我们联系。
|
||
|
||
### 九、政策更新
|
||
|
||
我们可能根据产品功能、法律法规或合规要求更新本政策。政策更新后,我们会在 App 内或相关页面展示更新后的版本;重大变更将通过弹窗、公告或其他合理方式提示您。
|
||
|
||
### 十、联系我们
|
||
|
||
如您对本政策、个人信息保护或账号注销有任何问题,可通过以下方式联系我们:
|
||
|
||
公司/运营主体:xxx
|
||
隐私负责人/客服邮箱:xxx
|
||
客服电话:xxx
|
||
联系地址:xxx''',
|
||
'terms': '''## 小脉健康服务协议
|
||
|
||
更新日期:2026年6月29日
|
||
生效日期:2026年6月29日
|
||
|
||
欢迎使用小脉健康。请您在注册、登录和使用本应用前,仔细阅读并理解本服务协议。您点击同意、注册、登录或继续使用本应用,即表示您已阅读、理解并同意本协议及《隐私政策》。
|
||
|
||
如您不同意本协议,请停止注册或使用本应用。
|
||
|
||
### 一、服务内容
|
||
|
||
小脉健康为用户提供健康数据记录、健康档案管理、用药管理、饮食识别、运动计划、健康日历、检查报告管理、AI 健康解释、在线医生咨询、随访和蓝牙健康设备同步等功能。具体服务内容会根据产品版本、实际开通情况、监管要求和运营安排进行调整。
|
||
|
||
本应用当前可能包含以下服务:
|
||
|
||
- 健康数据管理:记录和展示血压、心率、血糖、血氧、体重等指标。
|
||
- 蓝牙设备同步:连接支持的健康设备,当前主要用于血压计数据同步。
|
||
- 报告管理与预解读:上传检查报告图片,由系统进行结构化识别和 AI 辅助解读。
|
||
- 饮食识别:上传或拍摄饮食图片,识别食物种类、估算份量和热量。
|
||
- AI 健康咨询:根据您输入的文字、图片、PDF 或健康档案,提供健康解释和管理建议。
|
||
- 用药、运动和日历提醒:帮助您记录计划、查看进度和接收站内提醒。
|
||
- 在线医生咨询:在开通范围内与医生或相关服务人员进行消息沟通。
|
||
|
||
### 二、账号注册与使用
|
||
|
||
- 您应使用真实、准确、有效的信息完成注册、登录和资料填写。
|
||
- 您应妥善保管账号、验证码、登录状态及设备,不得将账号转让、出租、出借或提供给他人使用。
|
||
- 因您主动泄露验证码、账号信息、设备信息或操作不当造成的损失,由您自行承担。
|
||
- 如我们发现您的账号存在违法违规、异常访问、攻击系统、冒用身份、恶意上传等行为,有权依法采取限制功能、暂停服务、要求整改、注销账号或配合监管处理等措施。
|
||
|
||
### 三、健康服务和医疗边界
|
||
|
||
小脉健康是健康管理辅助工具,不是医疗机构,不提供急诊服务。本应用中的 AI 分析、健康提醒、饮食运动建议、报告预解读、风险提示和自动生成内容,仅供健康管理参考,不构成诊断、治疗、处方、用药调整或医疗结论。
|
||
|
||
您理解并同意:
|
||
|
||
- AI 回复可能受输入信息完整性、图片清晰度、模型能力、网络状态和第三方服务状态影响,可能存在不准确、不完整或延迟。
|
||
- 检查报告解读仅是对报告文字、指标或图片内容的辅助说明,不能替代医生结合病史、体格检查、影像资料和线下检查作出的诊断。
|
||
- 饮食识别、热量估算、运动建议和健康评分可能存在误差,仅作为参考。
|
||
- 用药相关内容不应替代医生、药师或说明书建议。请勿根据 App 内容自行开始、停止、更换或调整药物。
|
||
- 医生咨询、随访和报告审核等服务,应结合线下诊疗意见综合判断。
|
||
|
||
如您出现胸痛、胸闷憋气、呼吸困难、意识异常、晕厥、言语不清、一侧肢体无力、严重头痛、严重过敏、血压或血糖明显异常等紧急情况,请立即拨打急救电话或前往医疗机构就诊,不应等待或依赖本应用回复。
|
||
|
||
### 四、用户上传内容和行为规范
|
||
|
||
您在使用本应用时,可能会上传文字、图片、PDF、检查报告、健康数据、咨询内容或其他资料。您应保证上传内容来源合法、真实、准确,不侵犯他人合法权益。
|
||
|
||
您不得利用本应用从事以下行为:
|
||
|
||
- 提交虚假、违法、侵权、辱骂、歧视、色情、暴力、诈骗或误导性信息。
|
||
- 冒用他人身份,上传他人个人信息、健康数据、病历、报告或图片。
|
||
- 干扰应用正常运行,攻击、破解、扫描、爬取或尝试未经授权访问系统、数据和接口。
|
||
- 利用本应用从事违法违规、损害他人权益、扰乱医疗服务秩序或违反公序良俗的行为。
|
||
- 将 AI 回复、报告解读或医生咨询内容用于违法用途,或断章取义传播造成误导。
|
||
|
||
因您上传内容不真实、不合法、不完整或侵犯他人权益造成的后果,由您自行承担。
|
||
|
||
### 五、第三方服务和外部能力
|
||
|
||
为实现短信登录、AI 对话、图片识别、报告解读、知识库检索、文件存储、实时通信等功能,本应用可能依赖第三方或外部服务。实际启用的服务以正式部署配置为准。
|
||
|
||
您理解并同意:
|
||
|
||
- 第三方服务可能因网络、系统维护、接口限制、模型能力、政策调整等原因导致延迟、中断、识别失败或结果不准确。
|
||
- 我们会尽力保障服务稳定性,但不承诺第三方服务始终无错误、不中断或满足您的全部预期。
|
||
- 涉及个人信息和健康数据处理的第三方服务,我们会按照《隐私政策》进行说明,并在合理范围内要求其采取安全保护措施。
|
||
|
||
### 六、数据与隐私
|
||
|
||
我们会按照《隐私政策》收集、使用、存储、共享和保护您的个人信息及健康数据。您可在 App 内“设置”中查看《隐私政策》,并依法行使查询、更正、删除和注销账号等权利。
|
||
|
||
您可以在 App 内删除部分健康数据、报告、对话或其他记录;您也可以通过“删除账号”功能申请注销账号。账号注销后,系统会删除您的账号及主要业务数据,包括健康记录、用药记录、饮食记录、运动计划、报告记录、AI 对话、医生咨询、随访、通知、健康档案、登录令牌等。注销后相关数据不可恢复。
|
||
|
||
如法律法规要求留存,或为处理争议、审计、安全风控等确有必要,我们可能在法定或合理期限内保留必要记录。
|
||
|
||
### 七、服务变更、中断与终止
|
||
|
||
为提升服务质量、修复问题、满足合规要求或调整业务安排,我们可能对功能、页面、规则、服务范围、第三方能力或收费策略进行调整。
|
||
|
||
在以下情况下,服务可能发生中断、延迟或终止:
|
||
|
||
- 系统维护、升级、迁移、故障修复或安全加固。
|
||
- 网络、服务器、数据库、短信、AI 模型、云服务或第三方接口异常。
|
||
- 您的设备、系统版本、权限设置、网络环境或操作方式导致服务无法正常使用。
|
||
- 法律法规、监管要求、司法行政机关要求或不可抗力。
|
||
- 您违反本协议、法律法规或平台规则。
|
||
|
||
我们会尽力降低服务中断对您的影响,但不承诺服务永久、连续、无错误运行。
|
||
|
||
### 八、知识产权
|
||
|
||
本应用的软件、页面、图标、文字、图片、交互设计、技术方案、数据结构、算法逻辑、商标、标识及相关内容,除依法属于第三方或用户自行上传的内容外,相关权利归小脉健康或其合法权利人所有。
|
||
|
||
未经授权,您不得复制、修改、传播、出租、出售、反向工程、反编译、抓取、镜像或以其他方式使用本应用及相关内容。
|
||
|
||
您上传的文字、图片、报告、PDF、健康数据等内容的合法权利仍归您或原权利人所有。为向您提供服务,您授权我们在必要范围内对相关内容进行存储、处理、识别、分析、展示和传输。
|
||
|
||
### 九、责任限制
|
||
|
||
在法律允许范围内,小脉健康不对以下情况造成的损失承担超出法定范围的责任:
|
||
|
||
- 因您提供的信息不真实、不完整、不准确或操作不当导致的结果偏差。
|
||
- 因您将 AI 回复、报告预解读、饮食识别、运动建议或健康提醒作为诊断、治疗、处方或急救依据造成的后果。
|
||
- 因第三方服务、网络故障、设备异常、系统维护、不可抗力或监管要求导致的服务中断、数据延迟或功能不可用。
|
||
- 因您泄露账号、验证码、设备或登录状态导致的信息泄露或损失。
|
||
- 因您上传违法、侵权或不当内容引发的争议或责任。
|
||
|
||
本协议不排除或限制法律法规规定不得排除或限制的责任。
|
||
|
||
### 十、协议更新
|
||
|
||
我们可能根据业务发展、产品变化、法律法规或监管要求更新本协议。更新后,我们会在 App 内或相关页面展示新版本;重大变更将通过弹窗、公告或其他合理方式提示您。
|
||
|
||
如您在协议更新后继续使用本应用,视为您已接受更新后的协议。
|
||
|
||
### 十一、法律适用与争议解决
|
||
|
||
本协议的订立、履行、解释和争议解决适用中华人民共和国法律。因本协议或本应用服务产生争议的,双方应先友好协商;协商不成的,依法向有管辖权的人民法院解决。
|
||
|
||
### 十二、联系我们
|
||
|
||
如您对本协议或服务使用有任何疑问,请通过以下方式联系我们:
|
||
|
||
公司/运营主体:xxx
|
||
客服/支持邮箱:xxx
|
||
客服电话:xxx
|
||
联系地址:xxx''',
|
||
'about': '''## 关于小脉健康
|
||
|
||
版本:v1.0.0 (Build 20260101)
|
||
|
||
### 产品介绍
|
||
小脉健康是一款面向血管病患者的 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
|
||
|
||
### 核心功能
|
||
- AI 智能问诊:基于大语言模型的健康咨询服务
|
||
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
|
||
- 智能用药管理:AI 解析处方,自动生成用药计划和提醒
|
||
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
|
||
- 报告智能解读:上传检查报告,AI 自动提取指标并预解读
|
||
- 运动计划管理:制定和追踪每日运动目标
|
||
- 在线医生问诊:与签约医生进行远程咨询
|
||
|
||
### 开发团队
|
||
由专业医疗团队与 AI 技术团队联合打造。
|
||
|
||
### 技术支持
|
||
如遇到问题或有建议,请通过以下方式联系我们:
|
||
- 在线客服:App 内「设置」→「意见反馈」
|
||
- 客服热线:400-xxx-xxxx(工作日 9:00-18:00)
|
||
|
||
### 版权声明
|
||
© 2025-2026 小脉健康团队。保留所有权利。
|
||
本软件受中华人民共和国著作权法保护。''',
|
||
..._extraStaticTextContents,
|
||
};
|
||
return Scaffold(
|
||
backgroundColor: Colors.white,
|
||
appBar: AppBar(
|
||
backgroundColor: Colors.transparent,
|
||
elevation: 0,
|
||
scrolledUnderElevation: 0,
|
||
surfaceTintColor: Colors.transparent,
|
||
leading: IconButton(
|
||
icon: const Icon(Icons.chevron_left, color: AppColors.textPrimary),
|
||
onPressed: () => popRoute(ref),
|
||
),
|
||
title: Text(
|
||
titles[type] ?? '',
|
||
style: const TextStyle(
|
||
fontSize: 19,
|
||
color: AppColors.textPrimary,
|
||
fontWeight: FontWeight.w800,
|
||
),
|
||
),
|
||
centerTitle: true,
|
||
),
|
||
body: SingleChildScrollView(
|
||
padding: const EdgeInsets.fromLTRB(22, 18, 22, 36),
|
||
child: Center(
|
||
child: ConstrainedBox(
|
||
constraints: const BoxConstraints(maxWidth: 680),
|
||
child: MarkdownBody(
|
||
data: contents[type] ?? '内容加载中...',
|
||
selectable: true,
|
||
styleSheet: MarkdownStyleSheet(
|
||
textAlign: WrapAlignment.start,
|
||
p: const TextStyle(
|
||
fontSize: 15,
|
||
height: 1.85,
|
||
fontWeight: FontWeight.w500,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
h1Align: WrapAlignment.center,
|
||
h1Padding: const EdgeInsets.only(bottom: 18),
|
||
h1: const TextStyle(
|
||
fontSize: 24,
|
||
fontWeight: FontWeight.w900,
|
||
color: AppColors.textPrimary,
|
||
height: 1.35,
|
||
letterSpacing: 0,
|
||
),
|
||
h2Padding: const EdgeInsets.only(top: 18, bottom: 8),
|
||
h2: const TextStyle(
|
||
fontSize: 19,
|
||
fontWeight: FontWeight.w900,
|
||
color: AppColors.textPrimary,
|
||
height: 1.45,
|
||
letterSpacing: 0,
|
||
),
|
||
h3Padding: const EdgeInsets.only(top: 16, bottom: 6),
|
||
h3: const TextStyle(
|
||
fontSize: 17,
|
||
fontWeight: FontWeight.w800,
|
||
color: AppColors.textPrimary,
|
||
height: 1.45,
|
||
letterSpacing: 0,
|
||
),
|
||
listBullet: const TextStyle(
|
||
fontSize: 15,
|
||
height: 1.85,
|
||
color: AppColors.textPrimary,
|
||
),
|
||
blockSpacing: 10,
|
||
listIndent: 22,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
],
|
||
),
|
||
);
|