feat: 后端架构重构 — Endpoint→Service→Repository分层 + AI确认机制 + 异步任务持久化
- 核心业务拆分为 Endpoint → Application Service → Repository 三层 - AI写入操作必须用户确认后才写库(确认卡片机制) - 报告/饮食/用药分析改为持久化任务队列(原子领取/重试/重启恢复) - 运动计划修复: 连续真实日期替代周模板 - 用药提醒去重 + 通知Outbox预留 - 认证收拢到AuthService, 管理员收拢到AdminService - AI会话加用户归属校验防串号 - 提示词调整为患者视角 - 开发假数据已关闭 - 21/21测试通过, 0警告0错误
This commit is contained in:
@@ -1,3 +1,4 @@
|
||||
import 'dart:developer';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
@@ -19,7 +20,7 @@ class ApiClient {
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
)) {
|
||||
_dio.interceptors.add(_AuthInterceptor(this));
|
||||
_dio.interceptors.add(LogInterceptor(requestBody: true, responseBody: false));
|
||||
_dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
|
||||
}
|
||||
|
||||
Dio get dio => _dio;
|
||||
@@ -33,7 +34,8 @@ class ApiClient {
|
||||
}
|
||||
|
||||
Future<void> clearTokens() async {
|
||||
await _db.deleteAll();
|
||||
await _db.delete('access_token');
|
||||
await _db.delete('refresh_token');
|
||||
}
|
||||
|
||||
/// 带 token 的 GET 请求
|
||||
@@ -64,7 +66,19 @@ class ApiClient {
|
||||
final res = await _dio.post(path, data: formData);
|
||||
final data = res.data;
|
||||
if (data is Map) {
|
||||
return data['url']?.toString() ?? data['data']?['url']?.toString();
|
||||
final topUrl = data['url']?.toString();
|
||||
if (topUrl != null && topUrl.isNotEmpty) return topUrl;
|
||||
|
||||
final payload = data['data'];
|
||||
if (payload is Map) {
|
||||
final url = payload['url']?.toString();
|
||||
return url != null && url.isNotEmpty ? url : null;
|
||||
}
|
||||
if (payload is List && payload.isNotEmpty && payload.first is Map) {
|
||||
final first = payload.first as Map;
|
||||
final url = first['url']?.toString();
|
||||
return url != null && url.isNotEmpty ? url : null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -104,7 +118,7 @@ class _AuthInterceptor extends Interceptor {
|
||||
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts);
|
||||
return handler.resolve(retryResponse);
|
||||
}
|
||||
} catch (e) { print('[ApiClient] token刷新失败: $e'); }
|
||||
} catch (e) { log('[ApiClient] token刷新失败: $e'); }
|
||||
}
|
||||
await _client.clearTokens();
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const ReportListPage();
|
||||
case 'aiAnalysis':
|
||||
return AiAnalysisPage(id: params['id']!);
|
||||
case 'reportOriginal':
|
||||
return ReportOriginalPage(
|
||||
url: params['url'] ?? '',
|
||||
title: params['title'] ?? '原始报告',
|
||||
);
|
||||
case 'exercisePlan':
|
||||
return const ExercisePlanPage();
|
||||
case 'exerciseCreate':
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import 'package:sqflite/sqflite.dart';
|
||||
import 'package:path/path.dart';
|
||||
|
||||
/// SQLite 本地数据库——存储 token 等关键信息
|
||||
/// SQLite 本地键值缓存。
|
||||
///
|
||||
/// 约定:后端数据库是健康业务数据的唯一事实源。这里仅保存会话、偏好、
|
||||
/// 草稿和临时缓存,不保存健康记录、报告、用药、饮食、AI 对话等核心业务事实。
|
||||
class LocalDatabase {
|
||||
static LocalDatabase? _instance;
|
||||
Database? _db;
|
||||
|
||||
@@ -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('删除'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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('加载失败'))),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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});
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:developer';
|
||||
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import '../core/api_client.dart';
|
||||
@@ -79,7 +81,7 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
print('[Auth] loadProfile: $e');
|
||||
log('[Auth] loadProfile: $e');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +146,7 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
final db = ref.read(localDbProvider);
|
||||
final refresh = await db.read('refresh_token');
|
||||
if (refresh != null) {
|
||||
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth] logout: $e'); }
|
||||
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { log('[Auth] logout: $e'); }
|
||||
}
|
||||
await api.clearTokens();
|
||||
state = const AuthState(isLoggedIn: false, isLoading: false);
|
||||
|
||||
@@ -79,64 +79,60 @@ final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
|
||||
ChatNotifier.new,
|
||||
);
|
||||
|
||||
ActiveAgent _parseAgent(String? type) {
|
||||
switch (type?.toLowerCase()) {
|
||||
case 'consultation':
|
||||
return ActiveAgent.consultation;
|
||||
case 'health':
|
||||
return ActiveAgent.health;
|
||||
case 'diet':
|
||||
return ActiveAgent.diet;
|
||||
case 'medication':
|
||||
return ActiveAgent.medication;
|
||||
case 'report':
|
||||
return ActiveAgent.report;
|
||||
case 'exercise':
|
||||
return ActiveAgent.exercise;
|
||||
default:
|
||||
return ActiveAgent.default_;
|
||||
}
|
||||
}
|
||||
|
||||
class ChatNotifier extends Notifier<ChatState> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
Completer<void>? _streamDone;
|
||||
ActiveAgent? _lastTriggeredAgent;
|
||||
|
||||
void markNeedsRebuild() => state = state.copyWith();
|
||||
|
||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||
void confirmMessage(String id) {
|
||||
Future<String?> confirmMessage(String id) async {
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == id);
|
||||
if (i >= 0) {
|
||||
msgs[i].confirmed = true;
|
||||
state = state.copyWith(messages: msgs);
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
|
||||
final rawIds = msgs[i].metadata?['confirmationIds'];
|
||||
final confirmationIds = rawIds is List
|
||||
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
|
||||
: <String>[];
|
||||
if (confirmationIds.isEmpty) {
|
||||
return '确认信息已失效,请重新发送需要录入的内容';
|
||||
}
|
||||
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
for (final confirmationId in confirmationIds.toList()) {
|
||||
final response = await api.post('/api/ai/confirm-write/$confirmationId');
|
||||
final body = response.data;
|
||||
if (body is! Map || body['code'] != 0) {
|
||||
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
||||
}
|
||||
confirmationIds.remove(confirmationId);
|
||||
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
}
|
||||
|
||||
msgs[i].confirmed = true;
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(latestHealthProvider);
|
||||
}
|
||||
|
||||
void startEditingField(String msgId, String fieldLabel) {
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == msgId);
|
||||
if (i >= 0) {
|
||||
msgs[i].metadata?['_editingField'] = fieldLabel;
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
}
|
||||
|
||||
void finishEditingField(String msgId, String fieldLabel, String value) {
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == msgId);
|
||||
if (i >= 0) {
|
||||
if (value.isNotEmpty) msgs[i].metadata?[fieldLabel] = value;
|
||||
msgs[i].metadata?.remove('_editingField');
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
ChatState build() {
|
||||
ref.onDispose(() {
|
||||
_subscription?.cancel();
|
||||
_subscription = null;
|
||||
if (_streamDone != null && !_streamDone!.isCompleted) {
|
||||
_streamDone!.complete();
|
||||
}
|
||||
_streamDone = null;
|
||||
});
|
||||
Future.microtask(() {
|
||||
insertTaskCard();
|
||||
});
|
||||
@@ -162,7 +158,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
void setAgent(ActiveAgent a) {
|
||||
// 流式回复中忽略胶囊切换,防止状态混乱
|
||||
if (state.isStreaming) return;
|
||||
_subscription?.cancel();
|
||||
_cancelActiveStream();
|
||||
state = state.copyWith(activeAgent: a);
|
||||
ref.read(selectedAgentProvider.notifier).select(a);
|
||||
}
|
||||
@@ -198,7 +194,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}
|
||||
|
||||
Future<void> loadConversation(String convId) async {
|
||||
_subscription?.cancel();
|
||||
await _cancelActiveStream();
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final res = await api.get('/api/ai/conversations/$convId');
|
||||
@@ -277,11 +273,12 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
// 异步上传图片
|
||||
String? uploadedUrl;
|
||||
Object? uploadError;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
uploadedUrl = await api.uploadFile('/api/files/upload', file);
|
||||
} catch (_) {
|
||||
// 上传失败:保留本地路径,仍然可以本地显示
|
||||
} catch (e) {
|
||||
uploadError = e;
|
||||
}
|
||||
|
||||
// 更新消息元数据(保留本地路径 + 添加远程URL)
|
||||
@@ -300,6 +297,17 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
state = state.copyWith(messages: updatedMsgs);
|
||||
}
|
||||
|
||||
if (uploadedUrl == null) {
|
||||
final errorMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_upload_error',
|
||||
role: 'assistant',
|
||||
content: uploadError == null ? '图片上传失败,请稍后重试。' : '图片上传失败,请检查文件大小或网络后重试。',
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||
return;
|
||||
}
|
||||
|
||||
// 将图片 URL 作为消息内容发送给 AI
|
||||
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
|
||||
await _sendToAI(msgWithImage);
|
||||
@@ -352,9 +360,26 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
token: token,
|
||||
);
|
||||
|
||||
await for (final event in stream) {
|
||||
_processEvent(event, aiMsg);
|
||||
await _cancelActiveStream();
|
||||
final done = Completer<void>();
|
||||
_streamDone = done;
|
||||
_subscription = stream.listen(
|
||||
(event) => _processEvent(event, aiMsg),
|
||||
onError: (_) {
|
||||
_addError(aiMsg, '网络异常,请稍后重试');
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
onDone: () {
|
||||
if (!done.isCompleted) done.complete();
|
||||
},
|
||||
cancelOnError: true,
|
||||
);
|
||||
await done.future;
|
||||
if (_streamDone == done) {
|
||||
_streamDone = null;
|
||||
_subscription = null;
|
||||
}
|
||||
|
||||
if (state.isStreaming) {
|
||||
_done(aiMsg);
|
||||
}
|
||||
@@ -363,6 +388,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _cancelActiveStream() async {
|
||||
await _subscription?.cancel();
|
||||
_subscription = null;
|
||||
if (_streamDone != null && !_streamDone!.isCompleted) {
|
||||
_streamDone!.complete();
|
||||
}
|
||||
_streamDone = null;
|
||||
}
|
||||
|
||||
void _addError(ChatMessage aiMsg, String errorText) {
|
||||
aiMsg.content = errorText;
|
||||
aiMsg.type = MessageType.text;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:developer';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:signalr_netcore/signalr_client.dart';
|
||||
import '../core/api_client.dart' show baseUrl;
|
||||
@@ -197,7 +198,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
doctorTitle: doc['title']?.toString() ?? '',
|
||||
doctorDepartment: doc['department']?.toString() ?? '',
|
||||
);
|
||||
} catch (e) { print('[Consultation]请求失败: $e'); }
|
||||
} catch (e) { log('[Consultation]请求失败: $e'); }
|
||||
}
|
||||
|
||||
Future<void> _loadMessages(String consultationId) async {
|
||||
@@ -218,7 +219,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
if (msgs.isNotEmpty) {
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (e) { print('[Consultation]请求失败: $e'); }
|
||||
} catch (e) { log('[Consultation]请求失败: $e'); }
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
@@ -333,7 +334,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
|
||||
if (newMsgs.isNotEmpty) {
|
||||
state = state.copyWith(messages: [...state.messages, ...newMsgs]);
|
||||
}
|
||||
} catch (e) { print('[Consultation]请求失败: $e'); }
|
||||
} catch (e) { log('[Consultation]请求失败: $e'); }
|
||||
}
|
||||
|
||||
void stop() {
|
||||
|
||||
@@ -62,37 +62,9 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((r
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
try {
|
||||
return await service.getDoctors().timeout(const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
return _fallbackDoctors;
|
||||
}
|
||||
return service.getDoctors().timeout(const Duration(seconds: 8));
|
||||
});
|
||||
|
||||
const _fallbackDoctors = [
|
||||
{
|
||||
'id': '468b82e2-d95a-4436-bff6-a50eecf99a66',
|
||||
'name': '张明',
|
||||
'title': '主任医师',
|
||||
'department': '心脏康复科',
|
||||
'introduction': '擅长冠心病、高血压术后管理,20年临床经验',
|
||||
},
|
||||
{
|
||||
'id': 'd4148733-b538-4398-af17-0c7592fc0c2d',
|
||||
'name': '李芳',
|
||||
'title': '副主任医师',
|
||||
'department': '营养科',
|
||||
'introduction': '擅长糖尿病、甲状腺疾病管理,15年临床经验',
|
||||
},
|
||||
{
|
||||
'id': 'ef0953c9-eb63-4d03-b6d7-050a1897d4a3',
|
||||
'name': '王建国',
|
||||
'title': '主任医师',
|
||||
'department': '心血管内科',
|
||||
'introduction': '擅长术后营养指导、饮食方案制定,10年临床经验',
|
||||
},
|
||||
];
|
||||
|
||||
/// 问诊配额 Provider
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
@@ -102,23 +74,7 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
|
||||
/// 当前运动计划 Provider
|
||||
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final service = ref.watch(exerciseServiceProvider);
|
||||
try {
|
||||
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
return {
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': List.generate(7, (i) => {
|
||||
'id': 'local_$i',
|
||||
'dayOfWeek': i, // matches C# DayOfWeek: 0=Sun, 1=Mon, ..., 6=Sat
|
||||
'exerciseType': i == 1 || i == 6 ? '休息' : '散步',
|
||||
'durationMinutes': i == 1 || i == 6 ? 0 : 30,
|
||||
'isRestDay': i == 1 || i == 6,
|
||||
'isCompleted': false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
||||
});
|
||||
|
||||
/// 拍照/相册直接触发(无需跳转页面)
|
||||
|
||||
43
health_app/lib/services/in_app_notification_service.dart
Normal file
43
health_app/lib/services/in_app_notification_service.dart
Normal file
@@ -0,0 +1,43 @@
|
||||
import '../core/api_client.dart';
|
||||
|
||||
class InAppNotification {
|
||||
final String id;
|
||||
final String type;
|
||||
final String title;
|
||||
final String message;
|
||||
|
||||
const InAppNotification({
|
||||
required this.id,
|
||||
required this.type,
|
||||
required this.title,
|
||||
required this.message,
|
||||
});
|
||||
|
||||
factory InAppNotification.fromJson(Map<String, dynamic> json) =>
|
||||
InAppNotification(
|
||||
id: json['id']?.toString() ?? '',
|
||||
type: json['type']?.toString() ?? '',
|
||||
title: json['title']?.toString() ?? '健康提醒',
|
||||
message: json['message']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
class InAppNotificationService {
|
||||
final ApiClient _api;
|
||||
|
||||
const InAppNotificationService(this._api);
|
||||
|
||||
Future<List<InAppNotification>> getPending() async {
|
||||
final response = await _api.get('/api/notifications/pending');
|
||||
final items = response.data['data'] as List? ?? const [];
|
||||
return items
|
||||
.whereType<Map>()
|
||||
.map((item) => InAppNotification.fromJson(Map<String, dynamic>.from(item)))
|
||||
.where((item) => item.id.isNotEmpty)
|
||||
.toList();
|
||||
}
|
||||
|
||||
Future<void> acknowledge(String id) async {
|
||||
await _api.post('/api/notifications/$id/acknowledge');
|
||||
}
|
||||
}
|
||||
@@ -49,7 +49,7 @@ class OmronBleService {
|
||||
|
||||
static bool isBpDevice(ScanResult r) {
|
||||
final name = [
|
||||
r.advertisementData.localName,
|
||||
r.advertisementData.advName,
|
||||
r.device.platformName,
|
||||
].where((n) => n.isNotEmpty).join(' ').toUpperCase();
|
||||
return name.contains('OMRON') ||
|
||||
|
||||
Reference in New Issue
Block a user