feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化

- 核心业务拆分为 Endpoint → Application Service → Repository 三层
- AI写入操作必须用户确认后才写库(确认卡片机制)
- 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复)
- 运动计划修复: 连续真实日期替代周模板
- 用药提醒去重 + 通知Outbox预留
- 认证收拢到AuthService, 管理员收拢到AdminService
- AI会话加用户归属校验防串号
- 提示词调整为患者视角
- 开发假数据已关闭
- 21/21测试通过, 0警告0错误
This commit is contained in:
MingNian
2026-06-20 20:41:42 +08:00
parent c610417e29
commit 4d213b5a44
132 changed files with 6733 additions and 2856 deletions

View File

@@ -55,8 +55,8 @@ class _AdminDoctorsPageState extends ConsumerState<AdminDoctorsPage> {
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('删除'),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('删除'),
),
],
),

View File

@@ -56,11 +56,12 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
});
}
} catch (_) {
if (mounted)
if (mounted) {
setState(() {
_loading = false;
_loadingMore = false;
});
}
}
}
@@ -133,21 +134,23 @@ class _AdminPatientsPageState extends ConsumerState<AdminPatientsPage> {
: NotificationListener<ScrollNotification>(
onNotification: (n) {
if (n is ScrollEndNotification &&
n.metrics.pixels >= n.metrics.maxScrollExtent - 50)
n.metrics.pixels >= n.metrics.maxScrollExtent - 50) {
_loadMore();
}
return false;
},
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _patients.length + (_loadingMore ? 1 : 0),
itemBuilder: (_, i) {
if (i >= _patients.length)
if (i >= _patients.length) {
return const Center(
child: Padding(
padding: EdgeInsets.all(16),
child: CircularProgressIndicator(),
),
);
}
final p = _patients[i];
return Card(
margin: const EdgeInsets.only(bottom: 8),

View File

@@ -160,11 +160,12 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final active = doctors
.where((d) => d['isActive'] != false)
.toList();
if (active.isEmpty)
if (active.isEmpty) {
return const SizedBox(
height: 200,
child: Center(child: Text('暂无可用医生')),
);
}
return SizedBox(
height: 420,
child: Column(
@@ -228,7 +229,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
height: 200,
child: Center(child: CircularProgressIndicator()),
),
error: (_, __) =>
error: (_, _) =>
const SizedBox(height: 200, child: Center(child: Text('加载失败'))),
);
},

View File

@@ -218,10 +218,16 @@ class _TrendPageState extends ConsumerState<TrendPage> {
body['value'] = v1;
}
await api.post('/api/health-records', data: body);
if (!ctx.mounted || !mounted) {
return;
}
Navigator.pop(ctx);
ref.invalidate(latestHealthProvider);
_loadAll();
} catch (_) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('录入失败')));
@@ -454,8 +460,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
: 1,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _filtered.length)
if (idx < 0 || idx >= _filtered.length) {
return const SizedBox.shrink();
}
final d = _filtered[idx]['date'] as DateTime;
return Text(
'${d.month}/${d.day}',
@@ -482,7 +489,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
barWidth: 2.5,
dotData: FlDotData(
show: spots.length <= 60,
getDotPainter: (spot, _, __, ___) => FlDotCirclePainter(
getDotPainter: (spot, _, _, _) => FlDotCirclePainter(
radius: 3,
color: _color,
strokeWidth: 1,

View File

@@ -118,8 +118,8 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
final ble = ref.read(omronBleServiceProvider);
await ble.connect(r.device);
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '血压计');
await ref
@@ -414,7 +414,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
const SizedBox(height: 32),
AnimatedBuilder(
animation: _pulseAnim,
builder: (_, __) => Container(
builder: (_, _) => Container(
width: 48,
height: 48,
decoration: BoxDecoration(
@@ -448,8 +448,8 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
// ── 设备列表项 ──
Widget _buildTile(ScanResult r) {
final isConnecting = _connectingId == r.device.remoteId.toString();
final name = r.advertisementData.localName.isNotEmpty
? r.advertisementData.localName
final name = r.advertisementData.advName.isNotEmpty
? r.advertisementData.advName
: (r.device.platformName.isNotEmpty ? r.device.platformName : '未知设备');
return Container(

View File

@@ -129,8 +129,9 @@ class DietNotifier extends Notifier<DietState> {
);
String text = '';
await for (final event in stream) {
if (event['action'] == 'answer')
if (event['action'] == 'answer') {
text += (event['data'] as String?) ?? '';
}
if (event['action'] == 'status') {
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
}
@@ -323,6 +324,15 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
super.initState();
}
@override
void dispose() {
for (final ctrl in _fieldCtrls.values) {
ctrl.dispose();
}
_fieldCtrls.clear();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(dietProvider);

View File

@@ -19,7 +19,7 @@ class DoctorConsultationsPage extends ConsumerWidget {
final list = ref.watch(_consListProvider);
return list.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (items) => items.isEmpty
? const Center(
child: Text('暂无问诊', style: TextStyle(color: AppColors.textHint)),

View File

@@ -63,7 +63,7 @@ class DoctorDashboardPage extends ConsumerWidget {
dash.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (data) => _buildContent(context, ref, data),
),
],

View File

@@ -82,7 +82,7 @@ class _DoctorFollowUpEditPageState
const SizedBox(height: 8),
pts.when(
loading: () => const LinearProgressIndicator(),
error: (_, __) => const Text('加载失败'),
error: (_, _) => const Text('加载失败'),
data: (list) => _dropdown(list),
),
const SizedBox(height: 16),

View File

@@ -45,10 +45,11 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
@override
Widget build(BuildContext context) {
var items = ref.watch(_fupList);
if (_filter == 'Upcoming')
if (_filter == 'Upcoming') {
items = items.where((f) => f['status'] == 'Upcoming').toList();
else if (_filter == 'Completed')
} else if (_filter == 'Completed') {
items = items.where((f) => f['status'] == 'Completed').toList();
}
return Column(
children: [

View File

@@ -35,7 +35,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (data) => data == null
? const Center(child: Text('患者不存在'))
: _buildBody(data),
@@ -50,9 +50,6 @@ class DoctorPatientDetailPage extends ConsumerWidget {
final meds = data['medications'] as List? ?? [];
final reports = data['reports'] as List? ?? [];
final followUps = data['followUps'] as List? ?? [];
final trends = data['trendRecords'] as List? ?? [];
final diet = data['dietRecords'] as List? ?? [];
final exercise = data['exercisePlan'] as Map<String, dynamic>?;
return ListView(
padding: const EdgeInsets.all(16),
@@ -159,11 +156,12 @@ class DoctorPatientDetailPage extends ConsumerWidget {
...latest.map((r) {
final type = r['metricType']?.toString() ?? '';
String val = '';
if (type == 'BloodPressure')
if (type == 'BloodPressure') {
val =
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
else
} else {
val = '${r['value'] ?? '--'} ${r['unit'] ?? ''}';
}
return Padding(
padding: const EdgeInsets.only(bottom: 4),
child: Row(

View File

@@ -51,10 +51,11 @@ class _DoctorPatientsPageState extends ConsumerState<DoctorPatientsPage> {
final items =
(data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
setState(() {
if (reset)
if (reset) {
_patients = items;
else
} else {
_patients.addAll(items);
}
_total = data['total'] ?? 0;
_hasMore = _patients.length < _total;
_page++;

View File

@@ -53,7 +53,7 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
),
body: prof.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (d) {
if (d != null) {
_name.text = d['name'] ?? '';
@@ -111,21 +111,23 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
},
);
ref.invalidate(_docProfileProvider);
if (mounted)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存成功'),
backgroundColor: AppColors.success,
),
);
}
} catch (_) {
if (mounted)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppColors.error,
),
);
}
} finally {
if (mounted) setState(() => _saving = false);
}

View File

@@ -72,18 +72,20 @@ class _DoctorReportDetailPageState
);
setState(() => _submitted = true);
ref.invalidate(_reportDetailProvider(widget.id));
if (mounted)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('审核提交成功'),
backgroundColor: AppColors.success,
),
);
}
} catch (e) {
if (mounted)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('提交失败: $e'), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _submitting = false);
}
@@ -110,7 +112,7 @@ class _DoctorReportDetailPageState
),
body: detail.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (data) => data == null
? const Center(child: Text('报告不存在'))
: _buildBody(data),
@@ -253,7 +255,7 @@ class _DoctorReportDetailPageState
),
),
Text(
'${ind['value'] ?? ''}',
ind['value'] ?? '',
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
@@ -528,11 +530,11 @@ class _DoctorReportDetailPageState
),
const SizedBox(height: 12),
if (severity != null)
_InfoRow('严重程度', _severityLabels[severity] ?? severity),
_infoRow('严重程度', _severityLabels[severity] ?? severity),
if (doctorRec != null && doctorRec.isNotEmpty)
_InfoRow('建议', doctorRec),
_infoRow('建议', doctorRec),
if (doctorComment != null && doctorComment.isNotEmpty)
_InfoRow('评语', doctorComment),
_infoRow('评语', doctorComment),
],
),
),
@@ -546,8 +548,9 @@ class _DoctorReportDetailPageState
if (raw == null || raw.isEmpty) return [];
try {
final list = jsonDecode(raw);
if (list is List)
if (list is List) {
return list.map((e) => Map<String, String>.from(e as Map)).toList();
}
} catch (_) {}
return [];
}
@@ -559,7 +562,7 @@ class _DoctorReportDetailPageState
_ => AppColors.textHint,
};
Widget _InfoRow(String label, String value) => Padding(
Widget _infoRow(String label, String value) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@@ -63,7 +63,7 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
Expanded(
child: reports.when(
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, __) => const Center(child: Text('加载失败')),
error: (_, _) => const Center(child: Text('加载失败')),
data: (items) => items.isEmpty
? const Center(
child: Text(

View File

@@ -1,4 +1,5 @@
import 'dart:io';
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
@@ -11,6 +12,7 @@ import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart';
import '../../services/in_app_notification_service.dart';
import '../diet/diet_capture_page.dart';
import 'widgets/chat_messages_view.dart';
@@ -26,15 +28,74 @@ class _HomePageState extends ConsumerState<HomePage> {
final _focusNode = FocusNode();
String? _pickedImagePath;
int _lastMsgCount = 0;
Timer? _notificationTimer;
bool _checkingNotifications = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _checkNotifications());
_notificationTimer = Timer.periodic(
const Duration(seconds: 30),
(_) => _checkNotifications(),
);
}
@override
void dispose() {
_notificationTimer?.cancel();
_textCtrl.dispose();
_scrollCtrl.dispose();
_focusNode.dispose();
super.dispose();
}
Future<void> _checkNotifications() async {
if (!mounted || _checkingNotifications) return;
if (!ref.read(authProvider).isLoggedIn) return;
_checkingNotifications = true;
try {
final service = InAppNotificationService(ref.read(apiClientProvider));
final notifications = await service.getPending();
for (final notification in notifications) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 5),
content: Row(
children: [
Icon(
notification.type == 'ExerciseReminder'
? Icons.directions_run_outlined
: Icons.medication_outlined,
color: Colors.white,
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(notification.title,
style: const TextStyle(fontWeight: FontWeight.w700)),
Text(notification.message),
],
),
),
],
),
),
);
await service.acknowledge(notification.id);
}
} catch (_) {
// App 内提醒失败不阻断主页使用,下次轮询会重试。
} finally {
_checkingNotifications = false;
}
}
void _sendMessage() {
final text = _textCtrl.text.trim();
final imagePath = _pickedImagePath;

View File

@@ -6,7 +6,6 @@ import 'package:shadcn_ui/shadcn_ui.dart';
import '../../../core/app_colors.dart';
import '../../../core/app_theme.dart';
import '../../../core/navigation_provider.dart';
import '../../../providers/auth_provider.dart';
import '../../../providers/chat_provider.dart';
import '../../../providers/data_providers.dart';
@@ -503,21 +502,21 @@ class ChatMessagesView extends ConsumerWidget {
if (time.isNotEmpty) {
final timeValue = meta['服药时间'] as String? ?? time;
fields.add(
_ConfirmField(label: '服药时间', value: timeValue, editable: true),
_ConfirmField(label: '服药时间', value: timeValue),
);
}
final frequency = meta['frequency'] as String? ?? '';
if (frequency.isNotEmpty) {
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
fields.add(
_ConfirmField(label: '频率', value: freqValue, editable: true),
_ConfirmField(label: '频率', value: freqValue),
);
}
final duration = meta['duration_days'] as int?;
if (duration != null && duration > 0) {
final durationValue = meta['服用天数'] as String? ?? '$duration';
fields.add(
_ConfirmField(label: '服用天数', value: durationValue, editable: true),
_ConfirmField(label: '服用天数', value: durationValue),
);
}
} else if (isExercise) {
@@ -549,18 +548,18 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
if (dur.isNotEmpty) {
fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true));
fields.add(_ConfirmField(label: '运动时长', value: dur));
}
if (freq.isNotEmpty) {
fields.add(_ConfirmField(label: '频率', value: freq));
}
if (exDays > 0) {
fields.add(_ConfirmField(label: '计划天数', value: '${exDays}'));
fields.add(_ConfirmField(label: '计划天数', value: '$exDays天'));
}
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
if (intensity.isNotEmpty) {
fields.add(
_ConfirmField(label: '运动强度', value: intensity, editable: true),
_ConfirmField(label: '运动强度', value: intensity),
);
}
final calories =
@@ -568,7 +567,7 @@ class ChatMessagesView extends ConsumerWidget {
.toString();
if (calories.isNotEmpty) {
fields.add(
_ConfirmField(label: '消耗热量', value: '$calories kcal', editable: true),
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
);
}
} else {
@@ -582,13 +581,13 @@ class ChatMessagesView extends ConsumerWidget {
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
final timeValue = meta['记录时间'] as String? ?? recordTime;
fields.add(
_ConfirmField(label: '记录时间', value: timeValue, editable: true),
_ConfirmField(label: '记录时间', value: timeValue),
);
final note = meta['note'] as String? ?? '';
if (note.isNotEmpty) {
final noteValue = meta['备注'] as String? ?? note;
fields.add(
_ConfirmField(label: '备注', value: noteValue, editable: true),
_ConfirmField(label: '备注', value: noteValue),
);
}
}
@@ -851,7 +850,7 @@ class ChatMessagesView extends ConsumerWidget {
horizontal: 16,
),
),
_buildFieldRow(fields[i], msg, ref),
_buildFieldRow(fields[i]),
],
],
),
@@ -907,10 +906,15 @@ class ChatMessagesView extends ConsumerWidget {
: Material(
color: Colors.transparent,
child: InkWell(
onTap: () {
ref
onTap: () async {
final error = await ref
.read(chatProvider.notifier)
.confirmMessage(msg.id);
if (error != null && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(error)),
);
}
},
borderRadius: BorderRadius.circular(18),
child: Container(
@@ -958,9 +962,7 @@ class ChatMessagesView extends ConsumerWidget {
);
}
Widget _buildFieldRow(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
final isEditing = field.label == msg.metadata?['_editingField'];
Widget _buildFieldRow(_ConfirmField field) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(
@@ -975,67 +977,20 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(width: 12),
Expanded(
child: !field.editable
? Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
)
: isEditing
? _buildEditableTextField(field, msg, ref)
: InkWell(
onTap: () => ref
.read(chatProvider.notifier)
.startEditingField(msg.id, field.label),
child: Row(
children: [
Expanded(
child: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
Icon(
Icons.edit_outlined,
size: 18,
color: AppColors.border,
),
],
),
),
child: Text(
field.value.isNotEmpty ? field.value : '未设置',
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
),
],
),
);
}
Widget _buildEditableTextField(
_ConfirmField field,
ChatMessage msg,
WidgetRef ref,
) {
return _EditFieldCell(
key: ValueKey('editing_${msg.id}_${field.label}'),
initialValue: field.value,
onDone: (value) {
ref
.read(chatProvider.notifier)
.finishEditingField(msg.id, field.label, value);
},
);
}
void _editField(_ConfirmField field, ChatMessage msg, WidgetRef ref) {
// 字段可点击修改的入口,后续可接入 showDialog 实现
}
// ═══════════════════════════════════════════════════════════
// 3. ThinkingBubble — 思考动画
// ═══════════════════════════════════════════════════════════
@@ -1243,57 +1198,6 @@ class ChatMessagesView extends ConsumerWidget {
);
}
// ═══════════════════════════════════════════════════════════
// 公共组件:通用按钮
// ═══════════════════════════════════════════════════════════
Widget _cardFilledBtn(String label, IconData icon, {VoidCallback? onTap}) {
return ElevatedButton(
onPressed: onTap ?? () {},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 19),
const SizedBox(width: 5),
Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
);
}
Widget _cardOutlineBtn(String label, IconData icon, {VoidCallback? onTap}) {
return OutlinedButton(
onPressed: onTap ?? () {},
style: OutlinedButton.styleFrom(
foregroundColor: AppTheme.primary,
side: const BorderSide(color: AppTheme.primary, width: 1.2),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
padding: const EdgeInsets.symmetric(vertical: 11),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, size: 18),
const SizedBox(width: 4),
Text(
label,
style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w500),
),
],
),
);
}
// ═══════════════════════════════════════════════════════════
// 工具方法
// ═══════════════════════════════════════════════════════════
@@ -1428,45 +1332,6 @@ class ChatMessagesView extends ConsumerWidget {
}
}
static void _medicationCheckIn(WidgetRef ref, BuildContext context) async {
try {
final api = ref.read(apiClientProvider);
final reminders = await ref.read(medicationReminderProvider.future);
if (reminders.isEmpty) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('暂无待服药记录'),
backgroundColor: AppColors.warning,
),
);
return;
}
for (final m in reminders) {
final id = m['id']?.toString() ?? '';
final time = m['scheduledTime']?.toString() ?? '';
if (id.isEmpty) continue;
await api.post(
'/api/medications/$id/confirm-dose',
data: {'scheduledTime': time, 'status': 'taken'},
);
}
ref.invalidate(medicationReminderProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('打卡成功 已记录 ${reminders.length} 项服药'),
backgroundColor: AppTheme.success,
),
);
} catch (e) {
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('打卡失败:$e'), backgroundColor: Colors.red),
);
}
}
static _AgentColors _agentColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.consultation => _AgentColors(
@@ -1637,8 +1502,9 @@ class ChatMessagesView extends ConsumerWidget {
} else if (hasAbnormal) {
// 有异常时显示异常指标
final abnormalParts = <String>[];
if (bpAbnormal)
if (bpAbnormal) {
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
}
tasks.add(
_taskRow(
context,
@@ -1672,14 +1538,14 @@ class ChatMessagesView extends ConsumerWidget {
const exIconBg = Color(0xFFFEF3C7);
final exercisePlan = ref.watch(currentExercisePlanProvider);
var hasExercise = false;
exercisePlan.whenOrNull(
exercisePlan.when(
data: (plan) {
if (plan != null) {
final items =
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final today = now.weekday % 7;
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
(i) => i?['dayOfWeek'] == today,
(i) => i?['scheduledDate']?.toString() == today,
orElse: () => null,
);
if (todayItem != null && todayItem['isRestDay'] != true) {
@@ -1707,6 +1573,36 @@ class ChatMessagesView extends ConsumerWidget {
}
}
},
loading: () {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '正在加载运动计划',
status: 'pending',
iconColor: exIconColor,
iconBg: exIconBg,
onTap: () => pushRoute(ref, 'exercisePlan'),
),
);
},
error: (_, _) {
hasExercise = true;
tasks.add(
_taskRow(
context,
Icons.directions_run,
'运动',
trailing: '运动计划加载失败',
status: 'overdue',
iconColor: exIconColor,
iconBg: exIconBg,
onTap: () => pushRoute(ref, 'exercisePlan'),
),
);
},
);
if (!hasExercise) {
tasks.add(
@@ -2057,13 +1953,9 @@ class _AgentAction {
class _ConfirmField {
final String label;
final String value;
final IconData icon;
final bool editable;
const _ConfirmField({
required this.label,
required this.value,
this.icon = Icons.info_outline,
this.editable = false,
});
}
@@ -2215,58 +2107,3 @@ class _ExpandableAdviceState extends State<_ExpandableAdvice> {
);
}
}
class _EditFieldCell extends StatefulWidget {
final String initialValue;
final ValueChanged<String> onDone;
const _EditFieldCell({
super.key,
required this.initialValue,
required this.onDone,
});
@override
State<_EditFieldCell> createState() => _EditFieldCellState();
}
class _EditFieldCellState extends State<_EditFieldCell> {
late final TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(text: widget.initialValue);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) => TextField(
controller: _ctrl,
autofocus: true,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Color(0xFF1E293B),
),
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
filled: true,
fillColor: Colors.white,
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide(color: const Color(0xFF6366F1).withAlpha(80)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 1.5),
),
),
onSubmitted: widget.onDone,
onTapOutside: (_) => widget.onDone(_ctrl.text),
);
}

View File

@@ -64,8 +64,9 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
];
_timesPerDay = tod.length;
_times = tod;
if (m['startDate'] != null)
if (m['startDate'] != null) {
_start = DateTime.tryParse(m['startDate'].toString()) ?? DateTime.now();
}
if (m['endDate'] != null) _end = DateTime.tryParse(m['endDate'].toString());
setState(() {});
}
@@ -110,13 +111,14 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
popRoute(ref);
}
} catch (_) {
if (mounted)
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('保存失败'),
backgroundColor: AppTheme.error,
),
);
}
}
if (mounted) setState(() => _loading = false);
}
@@ -124,9 +126,12 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
void _updateTimes(int n) {
setState(() {
_timesPerDay = n;
while (_times.length < n)
while (_times.length < n) {
_times.add(const TimeOfDay(hour: 12, minute: 0));
while (_times.length > n) _times.removeLast();
}
while (_times.length > n) {
_times.removeLast();
}
});
}

View File

@@ -5,7 +5,6 @@ import '../core/app_theme.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../providers/omron_device_provider.dart';
import '../widgets/common_widgets.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
@@ -29,17 +28,19 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Future<void> _refresh() async {
final records = await ref.read(dietServiceProvider).getRecords();
if (mounted)
if (mounted) {
setState(() {
_data = records;
_loading = false;
});
}
}
Future<void> _delete(String id) async {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted)
if (mounted) {
setState(() => _data.removeWhere((d) => d['id']?.toString() == id));
}
}
String _dateKey(DateTime d) =>
@@ -67,7 +68,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
@override
Widget build(BuildContext context) {
if (_loading)
if (_loading) {
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -84,6 +85,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
),
body: const Center(child: CircularProgressIndicator()),
);
}
final todayRecords = _dayRecords(_selectedDate);
final totalCal = todayRecords.fold(
@@ -441,15 +443,17 @@ class _TrendChart extends StatelessWidget {
const _TrendChart(this.data);
@override
Widget build(BuildContext c) {
if (data.isEmpty)
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)
if (m == 0) {
return const Center(
child: Text('暂无数据', style: TextStyle(color: AppColors.textHint)),
);
}
return Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: List.generate(data.length, (i) {
@@ -549,10 +553,11 @@ class DietRecordDetailPage extends ConsumerWidget {
body: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getRecords(),
builder: (ctx, snap) {
if (!snap.hasData)
if (!snap.hasData) {
return const Center(
child: CircularProgressIndicator(color: AppTheme.primary),
);
}
final d = snap.data!.firstWhere(
(r) => r['id']?.toString() == id,
orElse: () => <String, dynamic>{},
@@ -734,11 +739,12 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
final done = items
.where((it) => it['isCompleted'] == true)
.length;
final weekStart = p['weekStartDate']?.toString() ?? '';
// 用 DayOfWeek 匹配今天C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
final todayCsDow = DateTime.now().weekday % 7;
final startDate = p['startDate']?.toString() ?? '';
final endDate = p['endDate']?.toString() ?? '';
final now = DateTime.now();
final todayKey = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
(it) => it['dayOfWeek'] == todayCsDow,
(it) => it['scheduledDate']?.toString() == todayKey,
orElse: () => <String, dynamic>{},
);
final hasTodayItem = todayItem.isNotEmpty;
@@ -786,7 +792,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
),
const SizedBox(height: 2),
Text(
'$weekStart · ${items.first['durationMinutes'] ?? 0}分钟/天',
'$startDate$endDate · ${items.first['durationMinutes'] ?? 0}分钟/天',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
@@ -855,12 +861,14 @@ class _ExercisePlanCreatePageState
extends ConsumerState<ExercisePlanCreatePage> {
final _nameCtrl = TextEditingController();
final _durationCtrl = TextEditingController(text: '30');
final _daysCtrl = TextEditingController(text: '7');
DateTime _start = DateTime.now();
DateTime _end = DateTime.now().add(const Duration(days: 6));
TimeOfDay _reminderTime = const TimeOfDay(hour: 19, minute: 0);
@override
void dispose() {
_nameCtrl.dispose();
_durationCtrl.dispose();
_daysCtrl.dispose();
super.dispose();
}
@@ -873,13 +881,16 @@ class _ExercisePlanCreatePageState
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')}',
'${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) {
@@ -904,9 +915,11 @@ class _ExercisePlanCreatePageState
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),
_dateField('结束日期', _end, (d) => setState(() => _end = d)),
_timeField(),
const SizedBox(height: 32),
SizedBox(
width: double.infinity,
@@ -1006,6 +1019,29 @@ class _ExercisePlanCreatePageState
],
);
}
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: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6),
GestureDetector(
onTap: () async {
final picked = await showTimePicker(context: 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: 19)),
),
),
],
);
}
}
/// 复查列表(从服务器读取)
@@ -1254,8 +1290,9 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
_diagnosisCtrl.text = a['diagnosis'] ?? '';
final st = a['surgeryType'] as String?;
final sd = a['surgeryDate'] as String?;
if (st != null && st.isNotEmpty)
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('') ?? '';
@@ -1839,19 +1876,16 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
'/api/calendar/day',
queryParameters: {'date': _dateKey(d)},
);
if (mounted)
if (mounted) {
setState(
() => _selectedDayData = res.data['data'] as Map<String, dynamic>?,
);
}
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final today = DateTime.now();
final isToday =
_selectedDate != null && _dateKey(_selectedDate!) == _dateKey(today);
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,
@@ -2053,13 +2087,14 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
};
Widget _buildDayDetail() {
if (_selectedDate == null)
if (_selectedDate == null) {
return const Center(
child: Text('点击日期查看当天安排', style: TextStyle(color: AppColors.textHint)),
);
}
if (_selectedDayData == null) {
final evs = _events[_dateKey(_selectedDate!)] ?? [];
if (evs.isEmpty)
if (evs.isEmpty) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -2077,10 +2112,11 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
],
),
);
}
}
final data = _selectedDayData;
if (data == null)
if (data == null) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
@@ -2094,6 +2130,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
],
),
);
}
final meds =
(data['medications'] as List?)?.cast<Map<String, dynamic>>() ?? [];
@@ -2359,437 +2396,6 @@ class StaticTextPage extends ConsumerWidget {
}
}
/// 旧版设备管理页,已移至 device/device_management_page.dart
class _DeviceManagementPageLegacy extends ConsumerWidget {
const _DeviceManagementPageLegacy({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final device = ref.watch(omronDeviceProvider);
return GradientScaffold(
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('蓝牙血压计'),
),
body: device.isBound
? _buildBoundCard(context, ref, device)
: _buildEmptyState(context, ref),
);
}
Widget _buildEmptyState(BuildContext context, WidgetRef ref) => Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 88,
height: 88,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(28),
),
child: Icon(
Icons.bluetooth_disabled,
size: 44,
color: AppColors.textHint,
),
),
const SizedBox(height: 20),
const Text(
'未绑定设备',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8),
const Text(
'连接欧姆龙血压计,自动同步测量数据',
style: TextStyle(fontSize: 15, color: AppColors.textHint),
),
const SizedBox(height: 28),
SizedBox(
width: 220,
height: 52,
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.add, size: 22),
label: const Text(
'添加设备',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
),
),
),
],
),
),
);
Widget _buildBoundCard(
BuildContext context,
WidgetRef ref,
DeviceBindState device,
) => SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
children: [
// 设备信息卡片
Container(
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(
children: [
Container(
width: 72,
height: 72,
decoration: BoxDecoration(
gradient: device.isConnected
? AppColors.primaryGradient
: const LinearGradient(
colors: [Color(0xFF9CA3AF), Color(0xFF6B7280)],
),
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: Icon(
device.isConnected
? Icons.bluetooth_connected
: Icons.bluetooth_disabled,
size: 36,
color: Colors.white,
),
),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 4,
),
decoration: BoxDecoration(
color: device.isConnected
? const Color(0xFFD1FAE5)
: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12),
),
child: Text(
device.isConnected ? '已连接' : '未连接',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: device.isConnected
? const Color(0xFF059669)
: const Color(0xFFDC2626),
),
),
),
const SizedBox(height: 12),
Text(
device.name ?? '血压计',
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 6),
Text(
'MAC: ${device.mac ?? ''}',
style: const TextStyle(fontSize: 14, color: AppColors.textHint),
),
if (device.lastSync != null) ...[
const SizedBox(height: 4),
Text(
'上次同步: ${device.lastSync}',
style: const TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
const SizedBox(height: 24),
if (device.isConnected) ...[
// 已连接:显示断开和解绑
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref),
icon: const Icon(
Icons.delete_outline,
size: 18,
color: AppColors.error,
),
label: const Text(
'解绑',
style: TextStyle(color: AppColors.error),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
),
] else ...[
// 未连接:显示重新连接和解绑
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: () => pushRoute(ref, 'deviceScan'),
icon: const Icon(Icons.bluetooth_searching, size: 20),
label: const Text(
'重新连接设备',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
),
),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => _unbind(context, ref),
icon: const Icon(
Icons.delete_outline,
size: 18,
color: AppColors.error,
),
label: const Text(
'解绑设备',
style: TextStyle(color: AppColors.error),
),
style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
],
],
),
),
// 最后读数卡片
if (device.lastReading != null) ...[
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow,
),
child: Column(
children: [
Row(
children: [
Container(
width: 6,
height: 18,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(3),
),
),
const SizedBox(width: 8),
const Text(
'最近一次测量',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 16),
Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: const Text('🩺', style: TextStyle(fontSize: 28)),
),
const SizedBox(width: 16),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
device.lastReading!.display,
style: const TextStyle(
fontSize: 32,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
letterSpacing: -0.5,
),
),
Text(
'mmHg',
style: TextStyle(
fontSize: 14,
color: AppColors.textHint,
),
),
],
),
const Spacer(),
if (device.lastReading!.pulse != null)
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${device.lastReading!.pulse}',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w700,
color: AppColors.primary,
),
),
const Text(
'bpm',
style: TextStyle(
fontSize: 13,
color: AppColors.textHint,
),
),
],
),
],
),
],
),
),
],
// 使用说明
const SizedBox(height: 16),
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
Icons.lightbulb_outline,
size: 18,
color: AppColors.primary,
),
const SizedBox(width: 8),
const Text(
'使用说明',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 10),
_guideItem('1', '血压计装好电池,绑好袖带'),
_guideItem('2', '血压计按开始键开始测量'),
_guideItem('3', '测量完成后数据自动同步到 App'),
_guideItem('4', '打开此页面时保持蓝牙开启'),
],
),
),
],
),
);
Widget _guideItem(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$num.',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.primary,
),
),
const SizedBox(width: 6),
Expanded(
child: Text(
text,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
),
],
),
);
void _unbind(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('解绑设备'),
content: const Text('解绑后需重新扫描连接,确定吗?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('确定解绑', style: TextStyle(color: AppColors.error)),
),
],
),
);
if (ok == true) {
await ref.read(omronDeviceProvider.notifier).unbind();
}
}
}
Widget _empty(BuildContext context, String title, String subtitle) => Center(
child: Column(
mainAxisSize: MainAxisSize.min,

View File

@@ -39,7 +39,12 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
}
final displayType = _displayName(analysis.reportType);
final isReviewed = reportItem?.status == 'DoctorReviewed';
final aiStatus = reportItem?.aiStatus ?? analysis.aiStatus;
final reviewStatus = reportItem?.reviewStatus ?? analysis.reviewStatus;
final fileUrl = reportItem?.fileUrl ?? analysis.fileUrl;
final isReviewed = reviewStatus == 'Reviewed';
final isAnalyzing = aiStatus == 'Analyzing';
final isFailed = aiStatus == 'Failed';
return GradientScaffold(
appBar: AppBar(
@@ -64,8 +69,43 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (fileUrl != null && fileUrl.isNotEmpty) ...[
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () => pushRoute(
ref,
'reportOriginal',
params: {'url': fileUrl, 'title': displayType},
),
icon: const Icon(Icons.image_outlined, size: 18),
label: const Text('查看原始报告'),
style: OutlinedButton.styleFrom(
foregroundColor: AppColors.primary,
side: const BorderSide(color: AppColors.primaryLight),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
const SizedBox(height: 12),
],
if (isAnalyzing || isFailed) ...[
_analysisStateCard(
isFailed
? (analysis.summary.isNotEmpty ? analysis.summary : 'AI 分析失败,请重新上传或稍后重试')
: 'AI 正在分析报告,请稍后刷新查看。',
isFailed: isFailed,
onRetry: isFailed
? () => ref.read(reportProvider.notifier).reanalyzeReport(widget.id)
: null,
),
const SizedBox(height: 20),
],
// ── 1. 指标分析 ──
if (analysis.indicators.isNotEmpty) ...[
if (!isAnalyzing && !isFailed && analysis.indicators.isNotEmpty) ...[
_sectionTitle('指标分析'),
const SizedBox(height: 8),
Container(
@@ -79,72 +119,76 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
const SizedBox(height: 20),
],
// ── 2. 综合解读 ──
_sectionTitle('综合解读'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
analysis.summary.isNotEmpty
? analysis.summary
: 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textPrimary,
height: 1.7,
),
),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读具体请以医生诊断为准',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
],
),
),
const SizedBox(height: 20),
// ── 3. 医生审核意见 ──
_sectionTitle('医生审核意见'),
const SizedBox(height: 8),
if (isReviewed)
_doctorReviewCard(reportItem!)
else
if (!isAnalyzing && !isFailed) ...[
_sectionTitle('综合解读'),
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: const Row(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
Icons.hourglass_empty,
size: 18,
color: AppColors.textHint,
),
SizedBox(width: 8),
Text(
'待审核',
style: TextStyle(
analysis.summary.isNotEmpty
? analysis.summary
: 'AI 正在分析中,请稍后刷新查看',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
color: AppColors.textPrimary,
height: 1.7,
),
),
Spacer(),
Text(
'AI 预解读',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
const SizedBox(height: 10),
Container(
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(10),
),
child: const Text(
'以上为AI预解读不能替代医生诊断和治疗建议',
style: TextStyle(fontSize: 13, color: AppColors.textHint),
),
),
],
),
),
const SizedBox(height: 20),
],
if (!isAnalyzing && !isFailed) ...[
// ── 3. 医生审核意见 ──
_sectionTitle('医生审核意见'),
const SizedBox(height: 8),
if (isReviewed)
_doctorReviewCard(reportItem!)
else
Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: const Row(
children: [
Icon(
Icons.hourglass_empty,
size: 18,
color: AppColors.textHint,
),
SizedBox(width: 8),
Text(
'待审核',
style: TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
Spacer(),
Text(
'AI 预解读',
style: TextStyle(fontSize: 14, color: AppColors.textHint),
),
],
),
),
],
const SizedBox(height: 30),
],
),
@@ -159,6 +203,63 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
boxShadow: AppColors.cardShadowLight,
);
Widget _analysisStateCard(
String message, {
required bool isFailed,
VoidCallback? onRetry,
}) {
final color = isFailed ? AppColors.error : AppColors.primary;
return Container(
padding: const EdgeInsets.all(16),
decoration: _cardDeco(),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
isFailed ? Icons.error_outline : Icons.hourglass_empty,
color: color,
),
const SizedBox(width: 10),
Expanded(
child: Text(
message,
style: TextStyle(
fontSize: 16,
height: 1.6,
color: isFailed ? AppColors.error : AppColors.textPrimary,
fontWeight: FontWeight.w600,
),
),
),
],
),
if (onRetry != null) ...[
const SizedBox(height: 14),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh, size: 18),
label: const Text('重新分析'),
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
),
),
],
],
),
);
}
Widget _sectionTitle(String text) => Row(
children: [
Container(

View File

@@ -1,12 +1,13 @@
import 'dart:convert';
import 'dart:async';
import 'dart:io';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/api_client.dart' show baseUrl;
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
@@ -19,12 +20,14 @@ class ReportState {
final String? uploadingImage;
final bool isAnalyzing;
final ReportAnalysis? currentAnalysis;
final String? uploadError;
ReportState({
this.reports = const [],
this.uploadingImage,
this.isAnalyzing = false,
this.currentAnalysis,
this.uploadError,
});
ReportState copyWith({
@@ -32,12 +35,21 @@ class ReportState {
String? uploadingImage,
bool? isAnalyzing,
ReportAnalysis? currentAnalysis,
String? uploadError,
bool clearUploadingImage = false,
bool clearCurrentAnalysis = false,
bool clearUploadError = false,
}) {
return ReportState(
reports: reports ?? this.reports,
uploadingImage: uploadingImage ?? this.uploadingImage,
uploadingImage: clearUploadingImage
? null
: uploadingImage ?? this.uploadingImage,
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
currentAnalysis: currentAnalysis ?? this.currentAnalysis,
currentAnalysis: clearCurrentAnalysis
? null
: currentAnalysis ?? this.currentAnalysis,
uploadError: clearUploadError ? null : uploadError ?? this.uploadError,
);
}
}
@@ -47,9 +59,12 @@ class ReportItem {
final String title;
final String type;
final DateTime uploadedAt;
final String? imagePath;
final String? fileUrl;
final bool hasAnalysis;
final String status; // PendingDoctor | DoctorReviewed
final String status; // Legacy combined status from backend.
final String aiStatus; // Analyzing | Succeeded | Failed
final String reviewStatus; // Pending | Reviewed
final String? analysisSummary;
final String? severity; // Normal | Abnormal | Severe | Critical
final String? doctorComment;
final String? doctorRecommendation;
@@ -61,9 +76,12 @@ class ReportItem {
required this.title,
required this.type,
required this.uploadedAt,
this.imagePath,
this.fileUrl,
this.hasAnalysis = false,
this.status = 'PendingDoctor',
this.aiStatus = 'Analyzing',
this.reviewStatus = 'Pending',
this.analysisSummary,
this.severity,
this.doctorComment,
this.doctorRecommendation,
@@ -77,12 +95,20 @@ class ReportAnalysis {
final String reportType;
final List<Indicator> indicators;
final String summary;
final String status;
final String aiStatus;
final String reviewStatus;
final String? fileUrl;
ReportAnalysis({
required this.reportId,
required this.reportType,
required this.indicators,
required this.summary,
this.status = 'PendingDoctor',
this.aiStatus = 'Analyzing',
this.reviewStatus = 'Pending',
this.fileUrl,
});
}
@@ -103,8 +129,11 @@ class Indicator {
}
class ReportNotifier extends Notifier<ReportState> {
Timer? _pollTimer;
@override
ReportState build() {
ref.onDispose(() => _pollTimer?.cancel());
Future.microtask(() => loadReports());
return ReportState();
}
@@ -129,8 +158,12 @@ class ReportNotifier extends Notifier<ReportState> {
uploadedAt:
DateTime.tryParse(m['createdAt']?.toString() ?? '') ??
DateTime.now(),
fileUrl: m['fileUrl']?.toString(),
hasAnalysis: m['aiSummary'] != null,
status: m['status']?.toString() ?? 'PendingDoctor',
aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m),
reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m),
analysisSummary: m['aiSummary']?.toString(),
severity: m['severity']?.toString(),
doctorComment: m['doctorComment']?.toString(),
doctorRecommendation: m['doctorRecommendation']?.toString(),
@@ -139,11 +172,25 @@ class ReportNotifier extends Notifier<ReportState> {
);
}).toList();
state = state.copyWith(reports: reports);
_syncAnalysisPolling(reports);
} catch (e) {
debugPrint('[Report] 加载报告列表失败: $e');
}
}
void _syncAnalysisPolling(List<ReportItem> reports) {
final hasAnalyzing = reports.any((r) => r.aiStatus == 'Analyzing');
if (!hasAnalyzing) {
_pollTimer?.cancel();
_pollTimer = null;
return;
}
_pollTimer ??= Timer.periodic(
const Duration(seconds: 4),
(_) => loadReports(),
);
}
void fetchReportDetail(String reportId) async {
try {
final api = ref.read(apiClientProvider);
@@ -164,6 +211,10 @@ class ReportNotifier extends Notifier<ReportState> {
reportType: displayType,
indicators: indicators,
summary: m['aiSummary']?.toString() ?? '',
status: m['status']?.toString() ?? 'PendingDoctor',
aiStatus: m['aiStatus']?.toString() ?? _deriveAiStatus(m),
reviewStatus: m['reviewStatus']?.toString() ?? _deriveReviewStatus(m),
fileUrl: m['fileUrl']?.toString(),
);
state = state.copyWith(currentAnalysis: analysis);
} catch (_) {
@@ -176,8 +227,21 @@ class ReportNotifier extends Notifier<ReportState> {
reportType: '检查报告',
indicators: [],
summary: '暂无数据,请下拉刷新重试',
status: 'AnalysisFailed',
aiStatus: 'Failed',
reviewStatus: 'Pending',
);
String _deriveAiStatus(Map<String, dynamic> m) {
final status = m['status']?.toString();
if (status == 'Analyzing') return 'Analyzing';
if (status == 'AnalysisFailed') return 'Failed';
return m['aiSummary'] != null ? 'Succeeded' : 'Analyzing';
}
String _deriveReviewStatus(Map<String, dynamic> m) =>
m['status']?.toString() == 'DoctorReviewed' ? 'Reviewed' : 'Pending';
List<Indicator> _parseIndicators(String? jsonStr) {
if (jsonStr == null || jsonStr.isEmpty) return [];
try {
@@ -198,7 +262,11 @@ class ReportNotifier extends Notifier<ReportState> {
}
void uploadImage(String path) async {
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
state = state.copyWith(
uploadingImage: path,
isAnalyzing: true,
clearUploadError: true,
);
try {
final api = ref.read(apiClientProvider);
final file = File(path);
@@ -211,14 +279,28 @@ class ReportNotifier extends Notifier<ReportState> {
// 直接上传到报告端点,后端自动触发 VLM+LLM 分析
final createRes = await api.dio.post('/api/reports', data: formData);
final data = createRes.data;
final reportId = data is Map
? (data['data']?['id']?.toString() ?? '')
: '';
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
if (data is Map && data['code'] != 0) {
final message = data['message']?.toString() ?? '上传失败,请稍后重试';
state = state.copyWith(
isAnalyzing: false,
clearUploadingImage: true,
uploadError: message,
);
return;
}
state = state.copyWith(
isAnalyzing: false,
clearUploadingImage: true,
clearUploadError: true,
);
loadReports();
} catch (_) {
state = state.copyWith(isAnalyzing: false, uploadingImage: null);
} catch (e) {
debugPrint('[Report] 上传失败: $e');
state = state.copyWith(
isAnalyzing: false,
clearUploadingImage: true,
uploadError: '上传失败,请检查网络或文件格式后重试',
);
}
}
@@ -237,8 +319,25 @@ class ReportNotifier extends Notifier<ReportState> {
}
}
Future<void> reanalyzeReport(String id) async {
try {
final res = await ref.read(apiClientProvider).post('/api/reports/$id/reanalyze');
final data = res.data;
if (data is Map && data['code'] != 0) {
state = state.copyWith(uploadError: data['message']?.toString() ?? '重新分析失败');
return;
}
state = state.copyWith(clearCurrentAnalysis: true, clearUploadError: true);
await loadReports();
fetchReportDetail(id);
} catch (e) {
debugPrint('[Report] 重新分析失败: $e');
state = state.copyWith(uploadError: '重新分析失败,请稍后重试');
}
}
void clearAnalysis() {
state = state.copyWith(currentAnalysis: null);
state = state.copyWith(clearCurrentAnalysis: true);
}
}
@@ -253,15 +352,18 @@ class ReportListPage extends ConsumerWidget {
if (state.isAnalyzing) {
return GradientScaffold(
appBar: AppBar(title: const Text('报告管理')),
body: const Center(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
CircularProgressIndicator(color: AppColors.primary),
SizedBox(height: 16),
const CircularProgressIndicator(color: AppColors.primary),
const SizedBox(height: 16),
Text(
'AI 正在分析报告...',
style: TextStyle(fontSize: 16, color: AppColors.textSecondary),
state.uploadingImage == null ? 'AI 正在分析报告...' : '正在上传报告...',
style: const TextStyle(
fontSize: 16,
color: AppColors.textSecondary,
),
),
],
),
@@ -281,14 +383,48 @@ class ReportListPage extends ConsumerWidget {
floatingActionButton: _buildUploadButton(context, ref),
body: RefreshIndicator(
onRefresh: () async => ref.read(reportProvider.notifier).loadReports(),
child: state.reports.isEmpty
? ListView(children: [_buildEmptyState(context)])
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.reports.length,
itemBuilder: (context, index) =>
_buildReportCard(context, ref, state.reports[index]),
child: ListView(
padding: const EdgeInsets.all(16),
children: [
if (state.uploadError != null) ...[
_buildUploadError(state.uploadError!),
const SizedBox(height: 12),
],
if (state.reports.isEmpty)
_buildEmptyState(context)
else
...state.reports.map(
(report) => _buildReportCard(context, ref, report),
),
],
),
),
);
}
Widget _buildUploadError(String message) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: AppColors.error.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: AppColors.error, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(
fontSize: 14,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
),
],
),
);
}
@@ -327,8 +463,9 @@ class ReportListPage extends ConsumerWidget {
source: ImageSource.camera,
imageQuality: 85,
);
if (picked != null)
if (picked != null) {
ref.read(reportProvider.notifier).uploadImage(picked.path);
}
},
),
ListTile(
@@ -344,26 +481,9 @@ class ReportListPage extends ConsumerWidget {
source: ImageSource.gallery,
imageQuality: 85,
);
if (picked != null)
if (picked != null) {
ref.read(reportProvider.notifier).uploadImage(picked.path);
},
),
ListTile(
leading: const Icon(
Icons.picture_as_pdf_outlined,
color: AppColors.primary,
),
title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: ['pdf'],
);
if (result != null && result.files.isNotEmpty)
ref
.read(reportProvider.notifier)
.uploadFile(result.files.first.path!);
}
},
),
],
@@ -471,66 +591,24 @@ class ReportListPage extends ConsumerWidget {
color: AppColors.textHint,
),
),
if (report.aiStatus == 'Failed') ...[
const SizedBox(height: 4),
Text(
_failureSummary(report),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 13,
height: 1.35,
color: AppColors.error,
fontWeight: FontWeight.w600,
),
),
],
],
),
),
if (report.status == 'DoctorReviewed')
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.successLight,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'已审核',
style: TextStyle(
fontSize: 13,
color: AppColors.success,
fontWeight: FontWeight.w500,
),
),
)
else if (!report.hasAnalysis)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.cardInner,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'分析中',
style: TextStyle(
fontSize: 13,
color: AppColors.textHint,
fontWeight: FontWeight.w500,
),
),
)
else
Container(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
decoration: BoxDecoration(
color: AppColors.warningLight,
borderRadius: BorderRadius.circular(8),
),
child: const Text(
'待审核',
style: TextStyle(
fontSize: 13,
color: AppColors.warning,
fontWeight: FontWeight.w500,
),
),
),
_buildStatusBadge(report),
const SizedBox(width: 4),
GestureDetector(
onTap: () => _confirmDelete(context, ref, report.id),
@@ -574,6 +652,40 @@ class ReportListPage extends ConsumerWidget {
);
}
Widget _buildStatusBadge(ReportItem report) {
final (label, bg, fg) = switch (report.status) {
_ when report.reviewStatus == 'Reviewed' => ('已审核', AppColors.successLight, AppColors.success),
_ when report.aiStatus == 'Analyzing' => ('分析中', AppColors.cardInner, AppColors.textHint),
_ when report.aiStatus == 'Failed' => ('分析失败', AppColors.error.withValues(alpha: 0.08), AppColors.error),
_ when report.aiStatus == 'Succeeded' => ('待审核', AppColors.warningLight, AppColors.warning),
_ => ('分析中', AppColors.cardInner, AppColors.textHint),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: bg,
borderRadius: BorderRadius.circular(8),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
color: fg,
fontWeight: FontWeight.w500,
),
),
);
}
String _failureSummary(ReportItem report) {
final summary = report.analysisSummary?.trim();
if (summary != null && summary.isNotEmpty) {
return summary;
}
return 'AI 暂时无法解读,请稍后重试或重新上传清晰图片';
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
@@ -593,3 +705,49 @@ String _catTitle(String c) => switch (c) {
'Image' => '影像检查报告',
_ => '检查报告',
};
class ReportOriginalPage extends StatelessWidget {
final String url;
final String title;
const ReportOriginalPage({
super.key,
required this.url,
this.title = '原始报告',
});
@override
Widget build(BuildContext context) {
final imageUrl = _absoluteUrl(url);
return GradientScaffold(
appBar: AppBar(title: Text(title)),
body: Center(
child: InteractiveViewer(
minScale: 0.7,
maxScale: 4,
child: Image.network(
imageUrl,
fit: BoxFit.contain,
errorBuilder: (_, _, _) => const Padding(
padding: EdgeInsets.all(24),
child: Text(
'原始报告图片加载失败',
style: TextStyle(color: AppColors.textSecondary),
),
),
),
),
),
);
}
String _absoluteUrl(String value) {
if (value.startsWith('http://') || value.startsWith('https://')) {
return value;
}
if (value.startsWith('/')) {
return '$baseUrl$value';
}
return '$baseUrl/$value';
}
}

View File

@@ -171,10 +171,11 @@ class NotificationPrefsPage extends ConsumerWidget {
context: context,
initialTime: const TimeOfDay(hour: 22, minute: 0),
);
if (picked != null && context.mounted)
if (picked != null && context.mounted) {
ref
.read(notificationPrefsProvider.notifier)
.setDndStart(picked);
}
},
),
),
@@ -197,10 +198,11 @@ class NotificationPrefsPage extends ConsumerWidget {
context: context,
initialTime: const TimeOfDay(hour: 8, minute: 0),
);
if (picked != null && context.mounted)
if (picked != null && context.mounted) {
ref
.read(notificationPrefsProvider.notifier)
.setDndEnd(picked);
}
},
),
),

View File

@@ -5,7 +5,6 @@ import '../../core/app_colors.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart' show apiClientProvider;
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});