Files
AI-Health/health_app/lib/pages/exercise/exercise_plan_page.dart
MingNian b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00

576 lines
19 KiB
Dart
Raw Permalink 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,
};