Files
AI-Health/health_app/lib/pages/medication/medication_checkin_page.dart
MingNian 335a3e6440 feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整
- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用
- 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方
- 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧
- 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影
- 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整
- 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
2026-07-12 22:49:38 +08:00

670 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.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/app_empty_state.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId;
const MedicationCheckInPage({super.key, this.medId});
@override
ConsumerState<MedicationCheckInPage> createState() =>
_MedicationCheckInPageState();
}
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
static const _visual = AppModuleVisuals.medication;
static const _softGreen = Color(0xFFEAF8EF);
Future<List<Map<String, dynamic>>>? _future;
final Set<String> _busyDoses = {};
@override
void initState() {
super.initState();
_load();
}
void _load() {
setState(() {
_future = ref.read(medicationReminderProvider.future);
});
}
Future<void> _refresh() async {
ref.invalidate(medicationReminderProvider);
_load();
await _future;
}
Future<void> _toggleDose(String medId, String time, bool taken) async {
final doseKey = '$medId-$time';
if (_busyDoses.contains(doseKey)) return;
setState(() => _busyDoses.add(doseKey));
try {
final api = ref.read(apiClientProvider);
if (taken) {
await api.delete('/api/medications/$medId/confirm-dose/$time');
} else {
await api.post(
'/api/medications/$medId/confirm-dose',
data: {'scheduledTime': time, 'status': 'taken'},
);
}
ref.invalidate(medicationReminderProvider);
ref.invalidate(medicationListProvider);
_load();
} catch (e) {
debugPrint('[MedCheckIn] 打卡失败: $e');
if (mounted) {
AppToast.show(context, '打卡失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _busyDoses.remove(doseKey));
}
}
@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 &&
!snap.hasData) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
if (snap.hasError) {
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
}
final reminders = _filterReminders(snap.data ?? []);
if (reminders.isEmpty) {
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 92),
AppEmptyState(
icon: Icons.task_alt_outlined,
title: '今天的用药已完成',
subtitle: '下拉可刷新最新用药提醒',
),
],
),
);
}
final grouped = _groupByMedication(reminders);
final total = reminders.length;
final taken = reminders.where(_isTaken).length;
final pending = total - taken;
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
children: [
_CheckInSummary(total: total, taken: taken, pending: pending),
const SizedBox(height: 14),
...grouped.entries.map(
(entry) => _MedicationDoseCard(
name: entry.key,
doses: entry.value,
busyDoses: _busyDoses,
onToggle: _toggleDose,
),
),
],
),
);
},
),
);
}
List<Map<String, dynamic>> _filterReminders(
List<Map<String, dynamic>> reminders,
) {
if (widget.medId == null) return reminders;
return reminders.where((r) => r['id']?.toString() == widget.medId).toList();
}
Map<String, List<Map<String, dynamic>>> _groupByMedication(
List<Map<String, dynamic>> reminders,
) {
final grouped = <String, List<Map<String, dynamic>>>{};
for (final reminder in reminders) {
final name = reminder['name']?.toString().trim();
grouped
.putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () {
return <Map<String, dynamic>>[];
})
.add(reminder);
}
for (final doses in grouped.values) {
doses.sort(
(a, b) => _formatTime(
a['scheduledTime']?.toString() ?? '',
).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')),
);
}
return grouped;
}
}
class _CheckInSummary extends StatelessWidget {
final int total;
final int taken;
final int pending;
const _CheckInSummary({
required this.total,
required this.taken,
required this.pending,
});
@override
Widget build(BuildContext context) {
final progress = total == 0 ? 0.0 : taken / total;
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: [
Container(
width: 42,
height: 42,
decoration: BoxDecoration(
gradient: _MedicationCheckInPageState._visual.gradient,
borderRadius: BorderRadius.circular(14),
),
child: const Icon(
Icons.medication_outlined,
color: Colors.white,
size: 24,
),
),
const SizedBox(width: 12),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'今日服药进度',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
SizedBox(height: 2),
Text(
'按提醒时间逐项确认,保持用药节奏',
style: TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
],
),
),
Text(
'${(progress * 100).round()}%',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: _MedicationCheckInPageState._visual.color,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 9,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: AlwaysStoppedAnimation<Color>(
_MedicationCheckInPageState._visual.color,
),
),
),
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _SummaryStat(
label: '待完成',
value: '$pending',
icon: Icons.schedule_outlined,
color: AppColors.warning,
),
),
const SizedBox(width: 8),
Expanded(
child: _SummaryStat(
label: '已打卡',
value: '$taken',
icon: Icons.check_circle_outline,
color: AppTheme.success,
),
),
const SizedBox(width: 8),
Expanded(
child: _SummaryStat(
label: '总次数',
value: '$total',
icon: Icons.format_list_numbered_outlined,
color: _MedicationCheckInPageState._visual.color,
),
),
],
),
],
),
);
}
}
class _SummaryStat extends StatelessWidget {
final String label;
final String value;
final IconData icon;
final Color color;
const _SummaryStat({
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: 11,
fontWeight: FontWeight.w700,
color: AppColors.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _MedicationDoseCard extends StatelessWidget {
final String name;
final List<Map<String, dynamic>> doses;
final Set<String> busyDoses;
final Future<void> Function(String medId, String time, bool taken) onToggle;
const _MedicationDoseCard({
required this.name,
required this.doses,
required this.busyDoses,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final dosage = doses.first['dosage']?.toString().trim() ?? '';
final takenCount = doses.where(_isTaken).length;
final allTaken = takenCount == doses.length;
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: allTaken
? AppTheme.success.withValues(alpha: 0.26)
: AppColors.border,
width: 1.1,
),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: allTaken
? _MedicationCheckInPageState._softGreen
: _MedicationCheckInPageState._visual.lightColor,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
),
child: Icon(
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
color: allTaken
? AppTheme.success
: _MedicationCheckInPageState._visual.color,
size: 24,
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
if (dosage.isNotEmpty) ...[
const SizedBox(height: 2),
Text(
dosage,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
],
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
decoration: BoxDecoration(
color: allTaken
? _MedicationCheckInPageState._softGreen
: AppColors.cardInner,
borderRadius: BorderRadius.circular(999),
),
child: Text(
'$takenCount/${doses.length}',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
color: allTaken
? AppTheme.success
: AppColors.textSecondary,
),
),
),
],
),
const SizedBox(height: 14),
for (var i = 0; i < doses.length; i++) ...[
_DoseRow(
dose: doses[i],
isLast: i == doses.length - 1,
isBusy: busyDoses.contains(
'${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}',
),
onToggle: onToggle,
),
],
],
),
);
}
}
class _DoseRow extends StatelessWidget {
final Map<String, dynamic> dose;
final bool isLast;
final bool isBusy;
final Future<void> Function(String medId, String time, bool taken) onToggle;
const _DoseRow({
required this.dose,
required this.isLast,
required this.isBusy,
required this.onToggle,
});
@override
Widget build(BuildContext context) {
final time = dose['scheduledTime']?.toString() ?? '';
final medId = dose['id']?.toString() ?? '';
final isTaken = _isTaken(dose);
final displayTime = _formatTime(time);
final statusColor = isTaken ? AppTheme.success : AppColors.warning;
return IntrinsicHeight(
child: Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: 28,
child: Column(
children: [
Container(
width: 22,
height: 22,
decoration: BoxDecoration(
color: isTaken ? AppTheme.success : Colors.white,
shape: BoxShape.circle,
border: Border.all(
color: isTaken ? AppTheme.success : AppColors.border,
width: 2,
),
),
child: isTaken
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
),
if (!isLast)
Expanded(
child: Container(
width: 2,
margin: const EdgeInsets.symmetric(vertical: 4),
color: isTaken
? AppTheme.success.withValues(alpha: 0.34)
: AppColors.borderLight,
),
),
],
),
),
const SizedBox(width: 10),
Expanded(
child: Padding(
padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
child: Container(
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
decoration: BoxDecoration(
color: isTaken
? _MedicationCheckInPageState._softGreen
: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: isTaken
? AppTheme.success.withValues(alpha: 0.18)
: AppColors.borderLight,
),
),
child: Row(
children: [
Icon(Icons.access_time, size: 18, color: statusColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
displayTime,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w800,
color: isTaken ? AppTheme.textSub : AppTheme.text,
),
),
const SizedBox(height: 1),
Text(
isTaken ? '已完成打卡' : '等待确认服用',
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: isTaken
? AppTheme.success
: AppColors.textSecondary,
),
),
],
),
),
const SizedBox(width: 8),
SizedBox(
height: 36,
child: FilledButton.icon(
onPressed: isBusy || medId.isEmpty || time.isEmpty
? null
: () => onToggle(medId, time, isTaken),
style: FilledButton.styleFrom(
backgroundColor: isTaken
? Colors.white
: _MedicationCheckInPageState._visual.color,
foregroundColor: isTaken
? AppTheme.success
: Colors.white,
disabledBackgroundColor: AppColors.cardInner,
disabledForegroundColor: AppColors.textHint,
padding: const EdgeInsets.symmetric(horizontal: 12),
minimumSize: const Size(0, 36),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(
color: isTaken
? AppTheme.success.withValues(alpha: 0.34)
: Colors.transparent,
),
),
),
icon: isBusy
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: AppColors.textHint,
),
)
: Icon(
isTaken
? Icons.undo_rounded
: Icons.check_rounded,
size: 17,
),
label: Text(
isTaken ? '撤销' : '打卡',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
),
),
),
),
],
),
),
),
),
],
),
);
}
}
bool _isTaken(Map<String, dynamic> dose) {
return dose['status']?.toString().toLowerCase() == 'taken';
}
String _formatTime(String raw) {
final value = raw.trim();
if (value.length >= 5 && value[2] == ':') return value.substring(0, 5);
final parsed = DateTime.tryParse(value);
if (parsed == null) return value;
final hour = parsed.hour.toString().padLeft(2, '0');
final minute = parsed.minute.toString().padLeft(2, '0');
return '$hour:$minute';
}