Files
AI-Health/health_app/lib/pages/exercise/exercise_plan_page.dart
MingNian 3b5cec10a6 feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router)
- 新增 AI 同意门控(用户需同意后才能使用 AI 功能)
- 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord)
- 新增 AI 意图路由(ai_intent_router)
- 新增医疗引用知识库(MedicalCitationKnowledge)
- 重构 prompt_manager 和 ai_chat_endpoints
- 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI
- 新增 agent 插画和趋势指标图标资源
- 删除 HANDOFF-2026-07-17.md
2026-07-27 16:53:20 +08:00

576 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 '../../providers/data_refresh_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> {
final Set<String> _busyItems = {};
final Set<String> _deletingPlans = {};
Future<void> _refresh() => ref.refresh(exercisePlansProvider.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.read(exerciseDataRefreshSignalProvider.notifier).trigger();
await ref.read(exercisePlansProvider.future);
} 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.read(exerciseDataRefreshSignalProvider.notifier).trigger();
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: AppAsyncValueView<List<Map<String, dynamic>>>(
value: ref.watch(exercisePlansProvider),
onRetry: () => ref.invalidate(exercisePlansProvider),
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,
),
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;
const _TodayExercisePanel({required this.items, required this.completed});
@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,
),
],
),
),
],
),
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.w600,
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: _phaseColor(phase).withValues(alpha: 0.10),
borderRadius: AppRadius.smBorder,
),
child: Icon(
AppModuleVisuals.exercise.icon,
color: _phaseColor(phase),
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,
};