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 createState() => _DietRecordListPageState(); } class _DietRecordListPageState extends ConsumerState { List> _data = []; bool _loading = true; int _trendDays = 7; DateTime _selectedDate = DateTime.now(); @override void initState() { super.initState(); _refresh(); } Future _refresh() async { final records = await ref.read(dietServiceProvider).getRecords(); if (mounted) { setState(() { _data = records; _loading = false; }); } } Future _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> _dayRecords(DateTime d) { final key = _dateKey(d); return _data .where((r) => (r['recordedAt']?.toString() ?? '').startsWith(key)) .toList(); } List _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>() ?? []; 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 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>>( 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: () => {}, ); if (d.isEmpty) return const Center(child: Text('记录不存在')); final items = (d['foodItems'] as List?)?.cast>() ?? []; 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 createState() => _ExercisePlanPageState(); } class _ExercisePlanPageState extends ConsumerState { static const _exerciseVisual = AppModuleVisuals.exercise; static const _exerciseBlue = AppColors.exercise; static const _exerciseViolet = Color(0xFF059669); Future>>? _future; final Set _busyItems = {}; @override void initState() { super.initState(); _load(); } void _load() => setState(() { _future = ref.read(exerciseServiceProvider).getPlans(); }); Future _refresh() async { _load(); await _future; } Future _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 _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>>( 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>() ?? >[]), ) .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>() ?? []; 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 plan; final List> items; final String todayKey; final Set busyItems; final Future 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: () => {}, ); 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( _ExercisePlanPageState._exerciseBlue, ), ), ), const SizedBox(height: 14), _ExerciseTodayInlineTask( item: todayItem, isBusy: busyItems.contains(todayItem['id']?.toString() ?? ''), onCheckIn: onCheckIn, ), ], ), ); } String _exerciseSummary(List> 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 item; final bool isBusy; final Future 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( _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 createState() => _ExercisePlanDetailPageState(); } class _ExercisePlanDetailPageState extends ConsumerState { Future?>? _future; final Set _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?>.value(null) : ref.read(exerciseServiceProvider).getPlanDetail(widget.id); }); } Future _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?>( future: _future, onRetry: _load, errorTitle: '运动计划详情加载失败', onData: (ctx, plan) { if (plan == null) { return _empty(context, '运动计划详情', '没有找到这条运动计划'); } final items = (plan['items'] as List?)?.cast>() ?? []; 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: () => {}, ); 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(0xFF60A5FA), ), ), ), ], ), ); } } class _TodayExerciseCard extends StatelessWidget { final Map 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 createState() => _ExercisePlanCreatePageState(); } class _ExercisePlanCreatePageState extends ConsumerState { 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 _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 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 createState() => _FollowUpListPageState(); } class _FollowUpListPageState extends ConsumerState { Future>>? _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>>( 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 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 createState() => _HealthArchivePageState(); } class _HealthArchivePageState extends ConsumerState { final _nameCtrl = TextEditingController(); final _genderCtrl = TextEditingController(); String _birthDate = ''; final _diagnosisCtrl = TextEditingController(); final List> _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 _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( (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 _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 _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 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 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 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 createState() => _HealthCalendarPageState(); } class _HealthCalendarPageState extends ConsumerState { DateTime _currentMonth = DateTime.now(); DateTime? _selectedDate; Map>> _events = {}; Map? _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 _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 = >>{}; for (final item in list) { final map = item as Map; final types = (map['events'] as List?)?.cast() ?? []; events[map['date'] as String] = types .map((t) => {'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 _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?, ); } } 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>() ?? []; final exs = (data['exercises'] as List?)?.cast>() ?? []; final fups = (data['followUps'] as List?)?.cast>() ?? []; 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 _extraStaticTextTitles = { 'personalInfoList': '个人信息收集清单', 'thirdPartySdkList': '第三方 SDK 共享清单', }; const Map _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), ], ), );