Files
AI-Health/health_app/lib/pages/exercise/exercise_plan_page.dart
MingNian 6e5d3e64cd feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色
- 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼
- app_colors / app_design_tokens / app_theme: 配色体系微调
- 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等)

## 登录页品牌升级
- 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent
- login_page 重构, 接入新品牌视觉
- app.dart 启动流程调整

## iOS / Android 配置
- Info.plist: 权限描述调整
- AppIcon / LaunchImage: 资源更新
- AndroidManifest: 权限微调

## 隐私文案修订
- privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述
- terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄

## 通知中心
- notification_center_page: 重构通知项布局

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +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.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: 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,
};