feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构

- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService
- 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放
- UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面
- 配置: api_client baseUrl 适配当前 WiFi IP
This commit is contained in:
MingNian
2026-07-06 12:44:59 +08:00
parent 4507083f3f
commit 7a93237069
38 changed files with 4165 additions and 1157 deletions

View File

@@ -1,150 +1,680 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_error_state.dart';
class MedicationCheckInPage extends ConsumerStatefulWidget {
final String? medId; // 可选指定药品null 显示全部
final String? medId;
const MedicationCheckInPage({super.key, this.medId});
@override ConsumerState<MedicationCheckInPage> createState() => _MedicationCheckInPageState();
@override
ConsumerState<MedicationCheckInPage> createState() =>
_MedicationCheckInPageState();
}
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
Future<List<Map<String, dynamic>>>? _future;
static const _medBlue = Color(0xFF60A5FA);
static const _medViolet = Color(0xFF8B5CF6);
static const _softGreen = Color(0xFFEAF8EF);
@override void initState() { super.initState(); _load(); }
void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); });
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) {
// 取消打卡:删掉对应 log
await api.delete('/api/medications/$medId/confirm-dose/$time');
} else {
// 打卡
await api.post('/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'});
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'); }
} catch (e) {
debugPrint('[MedCheckIn] 打卡失败: $e');
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('打卡失败,请稍后重试'),
backgroundColor: AppTheme.error,
),
);
}
} finally {
if (mounted) setState(() => _busyDoses.remove(doseKey));
}
}
@override Widget build(BuildContext context) {
@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),
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => popRoute(ref),
),
title: const Text('服药打卡'),
centerTitle: true,
),
body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future,
builder: (ctx, snap) {
if (snap.connectionState == ConnectionState.waiting) {
return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
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 = snap.data ?? [];
final reminders = _filterReminders(snap.data ?? []);
if (reminders.isEmpty) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint),
const SizedBox(height: 12),
const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)),
]),
return RefreshIndicator(
onRefresh: _refresh,
child: ListView(
physics: const AlwaysScrollableScrollPhysics(),
children: const [
SizedBox(height: 92),
AppEmptyState(
icon: Icons.task_alt_outlined,
title: '今天的用药已完成',
subtitle: '下拉可刷新最新用药提醒',
),
],
),
);
}
// 如果指定了 medId只显示该药品
var filtered = reminders;
if (widget.medId != null) {
filtered = reminders.where((r) => r['id']?.toString() == widget.medId).toList();
}
// 按药品分组
final grouped = <String, List<Map<String, dynamic>>>{};
for (final r in filtered) {
final name = r['name']?.toString() ?? '药品';
grouped.putIfAbsent(name, () => []).add(r);
}
final grouped = _groupByMedication(reminders);
final total = reminders.length;
final taken = reminders.where(_isTaken).length;
final pending = total - taken;
return RefreshIndicator(
onRefresh: () async => _load(),
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.all(14),
children: grouped.entries.map((entry) {
final medName = entry.key;
final doses = entry.value;
final dosage = doses.first['dosage']?.toString() ?? '';
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [AppTheme.shadowCard],
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,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)),
child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))),
),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
]),
]),
const SizedBox(height: 14),
...doses.map((d) {
final time = d['scheduledTime']?.toString() ?? '';
final status = d['status']?.toString() ?? 'pending';
final isTaken = status == 'taken';
final medId = d['id']?.toString() ?? '';
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(children: [
Icon(
isTaken ? Icons.check_circle : Icons.radio_button_unchecked,
size: 23,
color: isTaken ? AppTheme.success : AppTheme.border,
),
const SizedBox(width: 10),
Text(time, style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w500,
color: isTaken ? AppTheme.textSub : AppTheme.text,
)),
const Spacer(),
GestureDetector(
onTap: () => _toggleDose(medId, time, isTaken),
child: Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: isTaken ? AppColors.successLight : AppColors.cardInner,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
isTaken ? Icons.check_circle : Icons.check_circle_outline,
size: 28,
color: isTaken ? AppTheme.success : AppColors.textHint,
),
),
),
]),
);
}),
]),
);
}).toList(),
),
],
),
);
},
),
);
}
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: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_MedicationCheckInPageState._medBlue,
_MedicationCheckInPageState._medViolet,
],
),
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: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w900,
color: _MedicationCheckInPageState._medViolet,
),
),
],
),
const SizedBox(height: 16),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 9,
value: progress,
backgroundColor: AppColors.cardInner,
valueColor: const AlwaysStoppedAnimation<Color>(
_MedicationCheckInPageState._medBlue,
),
),
),
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._medViolet,
),
),
],
),
],
),
);
}
}
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
: AppColors.infoLight,
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._medBlue,
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._medBlue,
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';
}