Files
AI-Health/health_app/lib/pages/remaining_pages.dart
MingNian d82e006cf4 refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO
- 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心
- 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框
- 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影
- 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
2026-07-09 15:04:02 +08:00

3866 lines
136 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_markdown/flutter_markdown.dart';
import 'package:flutter_riverpod/flutter_riverpod.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_error_state.dart';
import '../widgets/app_future_view.dart';
import '../widgets/app_toast.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 {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted) {
setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
}
}
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 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.all(16),
children: [
// 热量趋势
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Text(
'热量趋势',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
const Spacer(),
_Toggle(
'7天',
_trendDays == 7,
() => setState(() => _trendDays = 7),
),
const SizedBox(width: 6),
_Toggle(
'30天',
_trendDays == 30,
() => setState(() => _trendDays = 30),
),
],
),
const SizedBox(height: 10),
SizedBox(height: 120, child: _TrendChart(_trendData())),
],
),
),
const SizedBox(height: 12),
// 饮食日历
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'饮食日历',
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: List.generate(7, (i) {
final d = DateTime.now().subtract(Duration(days: 6 - i));
final cal = _dayCalories(d);
final isToday = _dateKey(d) == _dateKey(DateTime.now());
final isSel = _dateKey(d) == _dateKey(_selectedDate);
return GestureDetector(
onTap: () => setState(() => _selectedDate = d),
child: Container(
width: 42,
margin: const EdgeInsets.symmetric(horizontal: 3),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: isSel
? AppColors.primary
: (isToday
? AppColors.primarySoft
: Colors.white),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isSel
? AppColors.primary
: (isToday
? AppColors.primary
: AppColors.border),
),
),
child: Column(
children: [
Text(
['', '', '', '', '', '', ''][d.weekday -
1],
style: TextStyle(
fontSize: 10,
color: isSel
? Colors.white
: (isToday
? AppColors.primary
: AppColors.textHint),
),
),
Text(
'${d.day}',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: isSel
? Colors.white
: (isToday
? AppColors.primary
: AppColors.textPrimary),
),
),
if (cal > 0)
Text(
'$cal',
style: TextStyle(
fontSize: 9,
color: isSel
? Colors.white70
: AppColors.textHint,
),
),
],
),
),
);
}),
),
),
],
),
),
const SizedBox(height: 12),
// 当日详情
Row(
children: [
Text(
'${_selectedDate.month}${_selectedDate.day}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
),
),
const SizedBox(width: 8),
Text(
'$totalCal 千卡',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
const Spacer(),
Text(
'${todayRecords.length}',
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
),
],
),
const SizedBox(height: 8),
if (todayRecords.isEmpty)
const Padding(
padding: EdgeInsets.all(20),
child: Center(
child: Text(
'当天暂无记录',
style: TextStyle(color: AppColors.textHint),
),
),
),
...todayRecords.map((d) {
final items =
(d['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[d['mealType']?.toString()] ?? '';
final mealIcon =
mealIcons[d['mealType']?.toString()] ?? Icons.restaurant;
final cal = d['totalCalories'] ?? 0;
final id = d['id']?.toString() ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ClipRRect(
borderRadius: BorderRadius.circular(14),
child: _SwipeAction(
onEdit: () => _showEditDialog(id, cal),
onDelete: () => _delete(id),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
mealIcon,
size: 20,
color: AppColors.primary,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
mealLabel,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(width: 8),
Text(
'$cal 千卡',
style: const TextStyle(
fontSize: 13,
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: 13,
color: AppColors.textHint,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
),
),
),
);
}),
],
),
);
}
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 _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: BorderRadius.circular(12),
border: Border.all(color: a ? AppColors.primary : 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 ? AppColors.primary : AppColors.border,
borderRadius: BorderRadius.circular(2),
),
),
],
),
),
);
}),
);
}
}
class _SwipeAction extends StatelessWidget {
final Widget child;
final VoidCallback onEdit;
final VoidCallback onDelete;
const _SwipeAction({
required this.child,
required this.onEdit,
required this.onDelete,
});
@override
Widget build(BuildContext c) => Dismissible(
key: UniqueKey(),
direction: DismissDirection.endToStart,
confirmDismiss: (_) async {
return false;
},
background: Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(14),
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: BorderRadius.circular(14),
color: AppColors.error,
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: const Icon(Icons.delete, color: Colors.white),
),
onDismissed: (_) => onDelete(),
onUpdate: (details) {
if (details.reached && details.direction == DismissDirection.startToEnd) {
onEdit();
}
},
child: child,
);
}
/// 饮食记录详情页(保留原功能)"""
class DietRecordDetailPage extends ConsumerWidget {
final String id;
const DietRecordDetailPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) {
final service = ref.watch(dietServiceProvider);
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: service.getRecords(),
builder: (ctx, snap) {
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
final d = snap.data!.firstWhere(
(r) => r['id']?.toString() == id,
orElse: () => <String, dynamic>{},
);
if (d.isEmpty) return const Center(child: Text('记录不存在'));
final items =
(d['foodItems'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final totalCal = d['totalCalories'] ?? 0;
final mealNames = {
'Breakfast': '早餐',
'Lunch': '午餐',
'Dinner': '晚餐',
'Snack': '加餐',
};
return ListView(
padding: const EdgeInsets.all(16),
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: const Color(0xFFE8ECF0)),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: const Icon(
Icons.restaurant,
size: 28,
color: AppTheme.primary,
),
),
const SizedBox(width: 14),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
mealNames[d['mealType']?.toString()] ?? '',
style: const TextStyle(
fontSize: 21,
fontWeight: FontWeight.w700,
),
),
Text(
'$totalCal 千卡 · ${d['recordedAt'] ?? ''}',
style: const TextStyle(
fontSize: 16,
color: AppColors.textHint,
),
),
],
),
],
),
),
const SizedBox(height: 16),
...items.map(
(f) => Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: const Color(0xFFE8ECF0)),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
f['name']?.toString() ?? '',
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w600,
),
),
if ((f['portion']?.toString() ?? '').isNotEmpty)
Text(
f['portion']!.toString(),
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
),
Text(
'${f['calories'] ?? 0} kcal',
style: const TextStyle(
fontSize: 18,
color: AppTheme.primary,
fontWeight: FontWeight.w600,
),
),
],
),
),
),
],
);
},
),
);
}
}
/// 运动计划列表(含打卡)
class ExercisePlanPage extends ConsumerStatefulWidget {
const ExercisePlanPage({super.key});
@override
ConsumerState<ExercisePlanPage> createState() => _ExercisePlanPageState();
}
class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
static const _exerciseVisual = AppModuleVisuals.exercise;
static const _exerciseBlue = AppColors.exercise;
static const _exerciseViolet = Color(0xFF059669);
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: FloatingActionButton(
onPressed: () {
pushRoute(ref, 'exerciseCreate');
},
backgroundColor: AppColors.exercise,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: const Icon(Icons.add, color: Colors.white),
),
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 _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: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_ExercisePlanPageState._exerciseBlue,
_ExercisePlanPageState._exerciseViolet,
],
),
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: AppColors.infoLight,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: Text(
'$done/$total',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: _ExercisePlanPageState._exerciseBlue,
),
),
),
],
),
const SizedBox(height: 14),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 8,
value: progress.clamp(0.0, 1.0),
backgroundColor: AppColors.cardInner,
valueColor: const AlwaysStoppedAnimation<Color>(
_ExercisePlanPageState._exerciseBlue,
),
),
),
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._exerciseBlue,
),
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._exerciseBlue,
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: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: _ExercisePlanPageState._exerciseViolet,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 9,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: const AlwaysStoppedAnimation<Color>(
_ExercisePlanPageState._exerciseBlue,
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _ExerciseSummaryStat(
label: '待完成',
value: '$pending',
icon: Icons.schedule_outlined,
color: AppColors.warning,
),
),
const SizedBox(width: 8),
Expanded(
child: _ExerciseSummaryStat(
label: '已打卡',
value: '$doneToday',
icon: Icons.check_circle_outline,
color: AppTheme.success,
),
),
const SizedBox(width: 8),
Expanded(
child: _ExerciseSummaryStat(
label: '计划数',
value: '$planCount',
icon: Icons.view_week_outlined,
color: _ExercisePlanPageState._exerciseViolet,
),
),
],
),
],
),
);
}
}
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 _exerciseColor = Color(0xFF60A5FA);
static const _exerciseDark = Color(0xFF7C5CFF);
static const _exerciseSoft = Color(0xFFEFF6FF);
@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(
color: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.directions_run,
color: Color(0xFF60A5FA),
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: const Color(0xFFEFF6FF),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: Text(
'$done/$total',
style: const TextStyle(
color: Color(0xFF60A5FA),
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: const Color(0xFFEFF6FF),
valueColor: const AlwaysStoppedAnimation<Color>(
Color(0xFF60A5FA),
),
),
),
],
),
);
}
}
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._exerciseSoft,
borderRadius: BorderRadius.circular(11),
),
child: Icon(
isDone ? Icons.check_circle : Icons.today_outlined,
color: isDone
? AppTheme.success
: _ExercisePlanDetailPageState._exerciseColor,
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._exerciseColor,
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._exerciseColor
: 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._exerciseSoft,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isCompleted ? Icons.check : Icons.fitness_center,
color: isCompleted
? AppTheme.success
: _ExercisePlanDetailPageState._exerciseDark,
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._exerciseColor,
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: const EdgeInsets.all(16),
children: [
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
const SizedBox(height: 16),
_field('每天时长(分钟)', _durationCtrl, hint: '30', number: true),
const SizedBox(height: 16),
_field('坚持天数', _daysCtrl, hint: '7', number: true),
const SizedBox(height: 16),
_dateField('开始日期', _start, (d) => setState(() => _start = d)),
const SizedBox(height: 16),
_timeField(),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: _save,
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
child: const Text(
'保存计划',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
),
],
),
);
}
Widget _field(
String label,
TextEditingController ctrl, {
String? hint,
bool number = false,
}) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
TextField(
controller: ctrl,
keyboardType: number ? TextInputType.number : null,
decoration: InputDecoration(
hintText: hint,
filled: true,
fillColor: Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide(color: AppColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
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, color: AppColors.textSecondary),
),
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: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(
_displayDate(val),
style: const TextStyle(fontSize: 16),
),
),
),
],
);
}
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, color: AppColors.textSecondary),
),
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: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: Text(value, style: const TextStyle(fontSize: 16)),
),
),
],
);
}
}
/// 复查列表(从服务器读取)
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')}';
}
}
/// 健康档案(可编辑)
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;
@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 p = await srv.getProfile();
if (p != null && mounted) {
_nameCtrl.text = p['name'] ?? '';
_genderCtrl.text = p['gender'] ?? '';
_birthDate = p['birthDate'] ?? '';
}
final a = await srv.getHealthArchive();
if (a != null && mounted) {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
final surgeries = a['surgeries'] as List?;
if (surgeries != null && surgeries.isNotEmpty) {
_surgeries.addAll(
surgeries.whereType<Map>().map(
(item) => {
'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({'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'] ?? '';
}
if (mounted) setState(() => _loading = false);
} catch (_) {
if (mounted) setState(() => _loading = false);
}
}
Future<void> _pickDate(void Function(String) onPicked) async {
final now = DateTime.now();
final d = await showAppDatePicker(
context,
initialDate: 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(() => _surgeries.add({'type': '', 'date': ''}));
void _removeSurgery(int i) => setState(() => _surgeries.removeAt(i));
void _setGender(String value) => setState(() => _genderCtrl.text = value);
Future<void> _save() async {
setState(() => _saving = true);
try {
final srv = ref.read(userServiceProvider);
await srv.updateProfile(
name: _nameCtrl.text,
gender: _genderCtrl.text,
birthDate: _birthDate,
);
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': _allergiesCtrl.text
.split('')
.where((s) => s.isNotEmpty)
.toList(),
'chronicDiseases': _chronicCtrl.text
.split('')
.where((s) => s.isNotEmpty)
.toList(),
'dietRestrictions': _dietCtrl.text
.split('')
.where((s) => s.isNotEmpty)
.toList(),
'familyHistory': _familyCtrl.text,
});
await ref.read(authProvider.notifier).refreshProfile();
} catch (_) {
if (mounted) {
AppToast.show(context, '保存失败,请重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _saving = false);
}
}
@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()),
);
}
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),
),
actions: [
_saving
? const Padding(
padding: EdgeInsets.only(right: 16),
child: SizedBox(
width: 24,
height: 24,
child: CircularProgressIndicator(strokeWidth: 2),
),
)
: TextButton(
onPressed: _save,
style: TextButton.styleFrom(
foregroundColor: AppColors.textPrimary,
),
child: const Text(
'保存',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w700),
),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_Section('个人资料', Icons.person_outline, [
_F('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
flex: 5,
child: _GenderF(
value: _genderCtrl.text,
onChanged: _setGender,
),
),
const SizedBox(width: 10),
Expanded(
flex: 6,
child: _DateF(
'出生日期',
_birthDate,
() => _pickDate((v) => setState(() => _birthDate = v)),
),
),
],
),
]),
const SizedBox(height: 12),
_Section('医疗信息', Icons.local_hospital_outlined, [
_F('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
const SizedBox(height: 14),
Row(
children: [
const Text(
'手术史',
style: TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const Spacer(),
_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: '如: 心脏搭桥',
),
),
const SizedBox(width: 8),
Expanded(
child: _DateF2(
'日期',
s['date'] ?? '',
() => _pickDate((v) => setState(() => s['date'] = v)),
hint: '点击选择',
),
),
GestureDetector(
onTap: () => _removeSurgery(i),
child: Container(
width: 32,
height: 32,
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: AppRadius.smBorder,
),
child: const Icon(
Icons.close,
size: 16,
color: AppColors.error,
),
),
),
],
),
);
}),
]),
const SizedBox(height: 12),
_Section('病史与限制', Icons.warning_amber_outlined, [
_F('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_F('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_F('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_F('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 40),
],
),
);
}
}
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 => switch (title) {
'个人资料' => const Color(0xFF2563EB),
'医疗信息' => const Color(0xFF7C5CFF),
'病史与限制' => const Color(0xFFF59E0B),
_ => AppColors.primary,
};
@override
Widget build(BuildContext c) => Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.smBorder,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 30,
height: 30,
decoration: BoxDecoration(
color: _color.withValues(alpha: 0.10),
borderRadius: AppRadius.smBorder,
),
child: Icon(icon, size: 18, color: _color),
),
const SizedBox(width: 10),
Text(
title,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 12),
...fields,
],
),
);
}
class _F extends StatelessWidget {
final String label;
final TextEditingController ctrl;
final String? hint;
const _F(this.label, this.ctrl, {this.hint});
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 14, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
TextField(
controller: ctrl,
style: const TextStyle(fontSize: 16),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
border: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: AppColors.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
),
),
),
],
);
}
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, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
Container(
height: 48,
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight),
),
child: Row(
children: [
Expanded(child: _GenderChip('', value == '', onChanged)),
const SizedBox(width: 4),
Expanded(child: _GenderChip('', value == '', onChanged)),
],
),
),
],
);
}
}
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 ? const Color(0xFF2563EB) : Colors.transparent,
borderRadius: AppRadius.smBorder,
),
child: Text(
label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
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});
@override
Widget build(BuildContext c) => Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textSecondary),
),
const SizedBox(height: 4),
TextField(
controller: TextEditingController(text: value),
onChanged: chg,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint),
filled: true,
fillColor: const Color(0xFFF8FAFC),
contentPadding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
border: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: AppColors.borderLight),
),
enabledBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: AppColors.borderLight),
),
focusedBorder: OutlineInputBorder(
borderRadius: AppRadius.smBorder,
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
),
),
),
],
);
}
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, color: AppColors.textSecondary),
),
const SizedBox(height: 6),
GestureDetector(
onTap: onTap,
child: Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight),
),
alignment: Alignment.centerLeft,
child: Row(
children: [
Text(
value.isEmpty ? '点击选择日期' : _slashDate(value),
style: TextStyle(
fontSize: 16,
color: value.isEmpty
? AppColors.textHint
: AppColors.textPrimary,
),
),
const Spacer(),
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: const Color(0xFFF8FAFC),
borderRadius: AppRadius.smBorder,
border: Border.all(color: AppColors.borderLight),
),
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).replaceAll('-', '/');
return date.replaceAll('-', '/');
}
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: const Color(0xFFEFF6FF),
borderRadius: AppRadius.smBorder,
border: Border.all(color: const Color(0xFFBFDBFE)),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.add, size: 14, color: Color(0xFF7C5CFF)),
SizedBox(width: 2),
Text(
'添加',
style: TextStyle(fontSize: 13, color: Color(0xFF7C5CFF)),
),
],
),
),
);
}
}
/// 健康日历
class HealthCalendarPage extends ConsumerStatefulWidget {
const HealthCalendarPage({super.key});
@override
ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
}
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
DateTime? _selectedDate;
Map<String, List<Map<String, dynamic>>> _events = {};
Map<String, dynamic>? _selectedDayData;
static const _medBlue = Color(0xFF3B82F6);
static const _exGreen = Color(0xFF60A5FA);
static const _fupOrange = Color(0xFFF59E0B);
static const _healthBlue = Color(0xFF2563EB);
@override
void initState() {
super.initState();
_loadMonth();
_selectDate(DateTime.now());
}
String _dateKey(DateTime d) =>
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
Future<void> _loadMonth() async {
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((t) => <String, dynamic>{'type': t})
.toList();
}
if (mounted) setState(() => _events = events);
// 如果有选中的日期,刷新详情
if (_selectedDate != null) _selectDate(_selectedDate!);
} catch (_) {}
}
void _selectDate(DateTime d) {
setState(() {
_selectedDate = d;
_selectedDayData = null;
});
final key = _dateKey(d);
final details = _events[key];
if (details != null && details.isNotEmpty) {
// 从后端拉详细数据
_loadDayDetail(d);
}
}
Future<void> _loadDayDetail(DateTime d) async {
try {
final api = ref.read(apiClientProvider);
final res = await api.get(
'/api/calendar/day',
queryParameters: {'date': _dateKey(d)},
);
if (mounted) {
setState(
() => _selectedDayData = res.data['data'] as Map<String, dynamic>?,
);
}
} catch (_) {}
}
@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: BorderRadius.circular(18),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
const SizedBox(height: 6),
_buildCalendarGrid(),
],
),
),
),
const SizedBox(height: 12),
// 图例
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_Legend('用药提醒', _medBlue),
const SizedBox(width: 10),
_Legend('运动计划', _exGreen),
const SizedBox(width: 10),
_Legend('复查随访', _fupOrange),
],
),
),
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: _healthBlue,
),
onPressed: () {
setState(
() => _currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month - 1,
),
);
_loadMonth();
},
),
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: _healthBlue,
),
onPressed: () {
setState(
() => _currentMonth = DateTime(
_currentMonth.year,
_currentMonth.month + 1,
),
);
_loadMonth();
},
),
],
),
);
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
? _healthBlue
: (isToday
? _healthBlue.withValues(alpha: 0.08)
: Colors.white),
borderRadius: BorderRadius.circular(12),
border: isSel
? Border.all(color: _healthBlue, width: 1.5)
: (isToday
? Border.all(
color: _healthBlue.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: isSel || isToday
? FontWeight.w600
: FontWeight.normal,
),
),
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' => _medBlue,
'exercise' => _exGreen,
'followup' => _fupOrange,
_ => AppColors.textHint,
};
Widget _buildDayDetail() {
if (_selectedDate == null) {
return const Center(
child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)),
);
}
if (_selectedDayData == null) {
final evs = _events[_dateKey(_selectedDate!)] ?? [];
if (evs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.event_available,
size: 40,
color: AppColors.textHint,
),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
}
final data = _selectedDayData;
if (data == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
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 Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.event_available, size: 40, color: AppColors.textHint),
const SizedBox(height: 8),
Text(
'${_selectedDate!.month}${_selectedDate!.day}日无事',
style: const TextStyle(color: AppColors.textHint),
),
],
),
);
}
return ListView(
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
// 用药
if (meds.isNotEmpty) ...[
_SectionTitle('用药提醒', _medBlue, Icons.medication),
...meds.map(
(m) => _EventCard(
'${m['name'] ?? ''} ${m['dosage'] ?? ''}',
'${m['timeOfDay'] ?? ''}',
_medBlue,
),
),
],
// 运动
if (exs.isNotEmpty) ...[
_SectionTitle('运动计划', _exGreen, Icons.fitness_center),
...exs.map(
(e) => _EventCard(
'${e['type'] ?? ''}',
'${e['duration'] ?? 0}分钟',
_exGreen,
),
),
],
// 随访
if (fups.isNotEmpty) ...[
_SectionTitle('复查随访', _fupOrange, Icons.event_note),
...fups.map(
(f) =>
_EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange),
),
],
],
);
}
}
class _SectionTitle extends StatelessWidget {
final String title;
final Color color;
final IconData icon;
const _SectionTitle(this.title, this.color, this.icon);
@override
Widget build(BuildContext c) => Padding(
padding: const EdgeInsets.only(top: 12, bottom: 6),
child: Row(
children: [
Container(
width: 6,
height: 20,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 8),
Icon(icon, size: 18, color: color),
const SizedBox(width: 6),
Text(
title,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: color,
),
),
],
),
);
}
class _EventCard extends StatelessWidget {
final String title;
final String sub;
final Color color;
const _EventCard(this.title, this.sub, this.color);
@override
Widget build(BuildContext c) => Container(
margin: const EdgeInsets.only(bottom: 6),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Row(
children: [
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.event_available, size: 18, color: color),
),
const SizedBox(width: 10),
Expanded(
child: Text(
title,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700),
),
),
Text(
sub,
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
),
);
}
class _Legend extends StatelessWidget {
final String label;
final Color color;
const _Legend(this.label, this.color);
@override
Widget build(BuildContext c) => Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: color.withValues(alpha: 0.12)),
),
child: Row(
children: [
Container(
width: 7,
height: 7,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 12,
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
),
],
);
}
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.white,
elevation: 0,
scrolledUnderElevation: 0.5,
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),
],
),
);