Files
AI-Health/health_app/lib/pages/exercise/exercise_plan_page.dart
MingNian fade61ac21 feat: 二级页面色彩刷新 + 用药/通知/设备重构 + 后端健康档案/通知管线增强 + 大量测试
## 后端
- 健康档案: 新增手术状态字段 + 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 目录
2026-07-15 23:22:52 +08:00

605 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/api_client.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/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/common_widgets.dart';
import '../care_plan_ui_logic.dart';
class EnterpriseExercisePlanPage extends ConsumerStatefulWidget {
const EnterpriseExercisePlanPage({super.key});
@override
ConsumerState<EnterpriseExercisePlanPage> createState() =>
_EnterpriseExercisePlanPageState();
}
class _EnterpriseExercisePlanPageState
extends ConsumerState<EnterpriseExercisePlanPage> {
Future<List<Map<String, dynamic>>>? _future;
final Set<String> _busyItems = {};
final Set<String> _deletingPlans = {};
@override
void initState() {
super.initState();
_load();
}
void _load() => setState(() {
_future = ref.read(exerciseServiceProvider).getPlans();
});
Future<void> _refresh() async {
_load();
await _future;
}
Future<void> _toggleCheckIn(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 (error) {
if (mounted) {
AppToast.show(
context,
error is ApiException ? error.message : '打卡失败,请稍后重试',
type: AppToastType.error,
);
}
} finally {
if (mounted) setState(() => _busyItems.remove(itemId));
}
}
Future<void> _confirmDelete(String id, String title) async {
if (id.isEmpty || _deletingPlans.contains(id)) return;
final confirmed = await showDialog<bool>(
context: context,
builder: (dialogContext) => AlertDialog(
title: const Text('删除运动计划'),
content: Text('确定删除“$title”吗?删除后无法恢复。'),
actions: [
TextButton(
onPressed: () => Navigator.pop(dialogContext, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(dialogContext, true),
style: TextButton.styleFrom(foregroundColor: AppColors.errorText),
child: const Text('删除'),
),
],
),
);
if (confirmed != true || !mounted) return;
setState(() => _deletingPlans.add(id));
try {
await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider);
_load();
if (mounted) {
AppToast.show(context, '运动计划已删除', type: AppToastType.success);
}
} catch (error) {
if (mounted) {
AppToast.show(
context,
error is ApiException ? error.message : '删除失败,请稍后重试',
type: AppToastType.error,
);
}
} finally {
if (mounted) setState(() => _deletingPlans.remove(id));
}
}
@override
Widget build(BuildContext context) {
return GradientScaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: const Text('运动计划'),
),
floatingActionButton: AppCreateFab(
tooltip: '新建运动计划',
onPressed: () => pushRoute(ref, 'exerciseCreate'),
),
body: AppFutureView<List<Map<String, dynamic>>>(
future: _future,
onRetry: _load,
errorTitle: '运动计划加载失败',
onData: (_, plans) {
if (plans.isEmpty) {
return AppEmptyState(
icon: AppModuleVisuals.exercise.icon,
title: '暂无运动计划',
subtitle: '点击右下角添加运动计划',
iconColor: AppColors.exercise,
);
}
final todayKey = _dateKey(DateTime.now());
final validPlans = plans.where((plan) {
return (plan['items'] as List?)?.isNotEmpty == true;
}).toList();
final todayItems = validPlans
.expand(_itemsOf)
.where((item) => item['scheduledDate']?.toString() == todayKey)
.where((item) => item['isRestDay'] != true)
.toList();
final completedToday = todayItems
.where((item) => item['isCompleted'] == true)
.length;
final groups = <CarePlanPhase, List<Map<String, dynamic>>>{
for (final phase in CarePlanPhase.values) phase: [],
};
for (final plan in validPlans) {
groups[resolveCarePlanPhase(
startDate: plan['startDate']?.toString(),
endDate: plan['endDate']?.toString(),
)]!
.add(plan);
}
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: AppSpacing.pageWithFab,
children: [
_TodayExercisePanel(
items: todayItems,
completed: completedToday,
busyItems: _busyItems,
onCheckIn: _toggleCheckIn,
),
const SizedBox(height: 18),
for (final phase in CarePlanPhase.values)
if (groups[phase]!.isNotEmpty) ...[
_ExercisePlanGroup(
phase: phase,
plans: groups[phase]!,
todayKey: todayKey,
busyItems: _busyItems,
deletingPlans: _deletingPlans,
onCheckIn: _toggleCheckIn,
onDelete: _confirmDelete,
),
const SizedBox(height: 18),
],
],
),
);
},
),
);
}
}
class _TodayExercisePanel extends StatelessWidget {
final List<Map<String, dynamic>> items;
final int completed;
final Set<String> busyItems;
final Future<void> Function(String) onCheckIn;
const _TodayExercisePanel({
required this.items,
required this.completed,
required this.busyItems,
required this.onCheckIn,
});
@override
Widget build(BuildContext context) {
final progress = items.isEmpty ? 0.0 : completed / items.length;
final next = items.cast<Map<String, dynamic>?>().firstWhere(
(item) => item?['isCompleted'] != true,
orElse: () => null,
);
if (items.isEmpty) {
return Container(
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Row(
children: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
color: AppColors.exerciseLight,
borderRadius: AppRadius.smBorder,
),
child: Icon(
AppModuleVisuals.exercise.icon,
color: AppColors.exercise,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('今日休息', style: AppTextStyles.sectionTitle),
SizedBox(height: 3),
Text('今天没有安排运动,保持轻松活动即可', style: AppTextStyles.listSubtitle),
],
),
),
],
),
);
}
return Container(
padding: AppSpacing.panel,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('今日任务', style: AppTextStyles.sectionTitle),
const SizedBox(height: 3),
Text(
'$completed / ${items.length} 已完成',
style: AppTextStyles.listSubtitle,
),
],
),
),
if (next != null)
_CompactActionButton(
busy: busyItems.contains(next['id']?.toString() ?? ''),
completed: false,
onPressed: () => onCheckIn(next['id']?.toString() ?? ''),
),
],
),
const SizedBox(height: 14),
ClipRRect(
borderRadius: AppRadius.pillBorder,
child: LinearProgressIndicator(
minHeight: 5,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: const AlwaysStoppedAnimation(AppColors.exercise),
),
),
if (next != null) ...[
const SizedBox(height: 12),
Text(
'${next['exerciseType'] ?? '运动'} · ${next['durationMinutes'] ?? 0} 分钟',
style: AppTextStyles.listTitle.copyWith(fontSize: 16),
),
],
],
),
);
}
}
class _ExercisePlanGroup extends StatelessWidget {
final CarePlanPhase phase;
final List<Map<String, dynamic>> plans;
final String todayKey;
final Set<String> busyItems;
final Set<String> deletingPlans;
final Future<void> Function(String) onCheckIn;
final Future<void> Function(String, String) onDelete;
const _ExercisePlanGroup({
required this.phase,
required this.plans,
required this.todayKey,
required this.busyItems,
required this.deletingPlans,
required this.onCheckIn,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final color = _phaseColor(phase);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 9),
child: Row(
children: [
Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 8),
Text(
'${_phaseLabel(phase)}${plans.length}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: color,
),
),
],
),
),
Container(
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
),
child: Column(
children: List.generate(plans.length, (index) {
final plan = plans[index];
final items = _itemsOf(plan);
final id = plan['id']?.toString() ?? '';
final title = _exerciseTitle(items);
final isDeleting = deletingPlans.contains(id);
return SwipeDeleteTile(
key: ValueKey(id),
margin: EdgeInsets.zero,
borderRadius: BorderRadius.zero,
enabled: !isDeleting,
onDelete: () => onDelete(id, title),
child: _ExercisePlanRow(
plan: plan,
title: title,
phase: phase,
todayKey: todayKey,
busyItems: busyItems,
deleting: isDeleting,
showDivider: index < plans.length - 1,
onCheckIn: onCheckIn,
),
);
}),
),
),
],
);
}
}
class _ExercisePlanRow extends StatelessWidget {
final Map<String, dynamic> plan;
final String title;
final CarePlanPhase phase;
final String todayKey;
final Set<String> busyItems;
final bool deleting;
final bool showDivider;
final Future<void> Function(String) onCheckIn;
const _ExercisePlanRow({
required this.plan,
required this.title,
required this.phase,
required this.todayKey,
required this.busyItems,
required this.deleting,
required this.showDivider,
required this.onCheckIn,
});
@override
Widget build(BuildContext context) {
final items = _itemsOf(plan);
final done = items.where((item) => item['isCompleted'] == true).length;
final progress = items.isEmpty ? 0.0 : done / items.length;
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(item) => item?['scheduledDate']?.toString() == todayKey,
orElse: () => null,
);
final canCheckIn =
phase == CarePlanPhase.active &&
todayItem != null &&
todayItem['isRestDay'] != true;
final itemId = todayItem?['id']?.toString() ?? '';
final completed = todayItem?['isCompleted'] == true;
return Material(
color: Colors.white,
child: Stack(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 12, 12),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.exerciseLight,
borderRadius: AppRadius.smBorder,
),
child: Icon(
AppModuleVisuals.exercise.icon,
color: AppColors.exercise,
size: 22,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.listTitle.copyWith(
fontSize: 17,
),
),
),
const SizedBox(width: 8),
Text(
'$done/${items.length}',
style: AppTextStyles.tag,
),
],
),
const SizedBox(height: 4),
Text(
'${_compactDate(plan['startDate'])} - ${_compactDate(plan['endDate'])}',
style: AppTextStyles.listSubtitle.copyWith(
fontSize: 13,
),
),
const SizedBox(height: 8),
ClipRRect(
borderRadius: AppRadius.pillBorder,
child: LinearProgressIndicator(
minHeight: 4,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: AlwaysStoppedAnimation(
_phaseColor(phase),
),
),
),
],
),
),
if (canCheckIn && !deleting) ...[
const SizedBox(width: 8),
_CompactActionButton(
busy: busyItems.contains(itemId) || deleting,
completed: completed,
onPressed: () => onCheckIn(itemId),
),
],
if (deleting)
const Padding(
padding: EdgeInsets.all(12),
child: SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
),
),
],
),
),
if (showDivider)
const Positioned(
left: 68,
right: 0,
bottom: 0,
child: Divider(
height: 1,
thickness: 0.7,
color: AppColors.divider,
),
),
],
),
);
}
}
class _CompactActionButton extends StatelessWidget {
final bool busy;
final bool completed;
final VoidCallback onPressed;
const _CompactActionButton({
required this.busy,
required this.completed,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38,
child: OutlinedButton(
onPressed: busy ? null : onPressed,
style: OutlinedButton.styleFrom(
padding: const EdgeInsets.symmetric(horizontal: 14),
backgroundColor: completed ? AppColors.successLight : Colors.white,
foregroundColor: completed
? AppColors.successText
: AppColors.exercise,
side: BorderSide(
color: completed ? AppColors.successText : AppColors.exercise,
),
shape: RoundedRectangleBorder(borderRadius: AppRadius.smBorder),
),
child: busy
? const SizedBox(
width: 15,
height: 15,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(completed ? '撤销' : '打卡', style: AppTextStyles.miniButton),
),
);
}
}
List<Map<String, dynamic>> _itemsOf(Map<String, dynamic> plan) =>
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
String _exerciseTitle(List<Map<String, dynamic>> items) {
final names = items
.where((item) => item['isRestDay'] != true)
.map((item) => item['exerciseType']?.toString().trim() ?? '')
.where((name) => name.isNotEmpty)
.toSet()
.toList();
if (names.isEmpty) return '运动计划';
return names.length == 1 ? names.first : '${names.first}${names.length}';
}
String _dateKey(DateTime date) =>
'${date.year}-${date.month.toString().padLeft(2, '0')}-${date.day.toString().padLeft(2, '0')}';
String _compactDate(Object? value) {
final parsed = DateTime.tryParse(value?.toString() ?? '');
return parsed == null
? '--'
: '${parsed.year}.${parsed.month.toString().padLeft(2, '0')}.${parsed.day.toString().padLeft(2, '0')}';
}
String _phaseLabel(CarePlanPhase phase) => switch (phase) {
CarePlanPhase.active => '进行中',
CarePlanPhase.upcoming => '即将开始',
CarePlanPhase.ended => '已结束',
};
Color _phaseColor(CarePlanPhase phase) => switch (phase) {
CarePlanPhase.active => AppColors.successText,
CarePlanPhase.upcoming => AppColors.blueMeasure,
CarePlanPhase.ended => AppColors.textHint,
};