feat: AI 提示词模块化 + AI 同意门控 + AI 草稿存储 + 意图路由 + 医疗引用知识库 + 多页面 UI 优化
- AI 提示词拆分为 markdown 模块(Prompts/global + modules + rag + router) - 新增 AI 同意门控(用户需同意后才能使用 AI 功能) - 新增 AI 录入草稿存储(AiEntryDraftContracts/EfAiEntryDraftStore/AiEntryDraftRecord) - 新增 AI 意图路由(ai_intent_router) - 新增医疗引用知识库(MedicalCitationKnowledge) - 重构 prompt_manager 和 ai_chat_endpoints - 优化聊天/趋势/档案/抽屉/医生端等多个页面 UI - 新增 agent 插画和趋势指标图标资源 - 删除 HANDOFF-2026-07-17.md
BIN
health_app/assets/branding/agent_illustration_consultation.png
Normal file
|
After Width: | Height: | Size: 994 KiB |
BIN
health_app/assets/branding/agent_illustration_diet.png
Normal file
|
After Width: | Height: | Size: 1.4 MiB |
BIN
health_app/assets/branding/agent_illustration_exercise.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/agent_illustration_health.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/agent_illustration_medication.png
Normal file
|
After Width: | Height: | Size: 1.2 MiB |
BIN
health_app/assets/branding/agent_illustration_report.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_blood_pressure.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_glucose.png
Normal file
|
After Width: | Height: | Size: 1.1 MiB |
BIN
health_app/assets/branding/trend_metric_heart_rate.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_spo2.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
BIN
health_app/assets/branding/trend_metric_weight.png
Normal file
|
After Width: | Height: | Size: 1.3 MiB |
@@ -9,7 +9,7 @@ const String baseUrl = String.fromEnvironment(
|
||||
'API_BASE_URL',
|
||||
defaultValue: kReleaseMode
|
||||
? 'https://erpapi.datalumina.cn/xiaomai'
|
||||
: 'http://10.4.178.175:5000',
|
||||
: 'http://192.168.1.34:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -168,3 +168,20 @@ class AppModuleVisuals {
|
||||
|
||||
static AppModuleVisual of(AppModule module) => values[module] ?? ai;
|
||||
}
|
||||
|
||||
/// 健康概览及所有健康指标卡片共用的图标与颜色。
|
||||
class HealthMetricVisuals {
|
||||
HealthMetricVisuals._();
|
||||
|
||||
static const bloodPressureIcon = Icons.bloodtype_outlined;
|
||||
static const heartRateIcon = LucideIcons.heartPulse;
|
||||
static const glucoseIcon = Icons.water_drop_outlined;
|
||||
static const spo2Icon = Icons.air_outlined;
|
||||
static const weightIcon = Icons.monitor_weight_outlined;
|
||||
|
||||
static const bloodPressureColor = Color(0xFF8B5CF6);
|
||||
static const heartRateColor = Color(0xFFF43F5E);
|
||||
static const glucoseColor = Color(0xFFF59E0B);
|
||||
static const spo2Color = Color(0xFF0EA5E9);
|
||||
static const weightColor = Color(0xFF10B981);
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/settings/elder_mode_page.dart';
|
||||
import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/settings/ai_consent_details_page.dart';
|
||||
import '../pages/notifications/notification_center_page.dart';
|
||||
import '../pages/history/conversation_history_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
@@ -33,6 +34,7 @@ import '../pages/admin/admin_home_page.dart';
|
||||
import '../pages/admin/admin_add_doctor_page.dart';
|
||||
import '../providers/auth_provider.dart' show userRoleProvider;
|
||||
import '../widgets/app_error_state.dart';
|
||||
import '../widgets/ai_consent_gate.dart';
|
||||
|
||||
Widget _missingParamPage() {
|
||||
return const Scaffold(
|
||||
@@ -53,7 +55,9 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const LoginPage();
|
||||
case 'home':
|
||||
final role = ref.watch(userRoleProvider);
|
||||
return role == 'Doctor' ? const DoctorHomePage() : const HomePage();
|
||||
return role == 'Doctor'
|
||||
? const DoctorHomePage()
|
||||
: AiConsentGate(child: const HomePage());
|
||||
case 'doctorHome':
|
||||
return const DoctorHomePage();
|
||||
case 'adminHome':
|
||||
@@ -132,6 +136,8 @@ Widget buildPage(RouteInfo route, WidgetRef ref) {
|
||||
return const ElderModePage();
|
||||
case 'notificationPrefs':
|
||||
return const NotificationPrefsPage();
|
||||
case 'aiConsentDetails':
|
||||
return const AiConsentDetailsPage();
|
||||
case 'notifications':
|
||||
return const NotificationCenterPage();
|
||||
case 'conversationHistory':
|
||||
|
||||
@@ -3,9 +3,9 @@ import 'dart:math';
|
||||
import 'package:fl_chart/fl_chart.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -27,11 +27,11 @@ const Set<String> supportedTrendMetricTypes = {
|
||||
class _TrendColors {
|
||||
_TrendColors._();
|
||||
|
||||
static const bloodPressure = Color(0xFFD45B68);
|
||||
static const heartRate = Color(0xFFD97757);
|
||||
static const glucose = Color(0xFF4F6EF7);
|
||||
static const spo2 = Color(0xFF168F7A);
|
||||
static const weight = Color(0xFF7559C7);
|
||||
static const bloodPressure = HealthMetricVisuals.bloodPressureColor;
|
||||
static const heartRate = HealthMetricVisuals.heartRateColor;
|
||||
static const glucose = HealthMetricVisuals.glucoseColor;
|
||||
static const spo2 = HealthMetricVisuals.spo2Color;
|
||||
static const weight = HealthMetricVisuals.weightColor;
|
||||
static const systolic = bloodPressure;
|
||||
static const diastolic = Color(0xFF3B82F6);
|
||||
}
|
||||
@@ -132,31 +132,31 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
'key': 'blood_pressure',
|
||||
'label': '血压',
|
||||
'color': _TrendColors.bloodPressure,
|
||||
'icon': LucideIcons.gauge,
|
||||
'icon': HealthMetricVisuals.bloodPressureIcon,
|
||||
},
|
||||
{
|
||||
'key': 'heart_rate',
|
||||
'label': '心率',
|
||||
'color': _TrendColors.heartRate,
|
||||
'icon': LucideIcons.heartPulse,
|
||||
'icon': HealthMetricVisuals.heartRateIcon,
|
||||
},
|
||||
{
|
||||
'key': 'glucose',
|
||||
'label': '血糖',
|
||||
'color': _TrendColors.glucose,
|
||||
'icon': LucideIcons.droplet,
|
||||
'icon': HealthMetricVisuals.glucoseIcon,
|
||||
},
|
||||
{
|
||||
'key': 'spo2',
|
||||
'label': '血氧',
|
||||
'color': _TrendColors.spo2,
|
||||
'icon': LucideIcons.wind,
|
||||
'icon': HealthMetricVisuals.spo2Icon,
|
||||
},
|
||||
{
|
||||
'key': 'weight',
|
||||
'label': '体重',
|
||||
'color': _TrendColors.weight,
|
||||
'icon': LucideIcons.scale,
|
||||
'icon': HealthMetricVisuals.weightIcon,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -176,6 +176,14 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
'weight': '体重',
|
||||
};
|
||||
|
||||
static const _latestCardArt = {
|
||||
'blood_pressure': 'assets/branding/trend_metric_blood_pressure.png',
|
||||
'heart_rate': 'assets/branding/trend_metric_heart_rate.png',
|
||||
'glucose': 'assets/branding/trend_metric_glucose.png',
|
||||
'spo2': 'assets/branding/trend_metric_spo2.png',
|
||||
'weight': 'assets/branding/trend_metric_weight.png',
|
||||
};
|
||||
|
||||
String get _unit => _units[_selected] ?? '';
|
||||
String get _name => _names[_selected] ?? '';
|
||||
bool get _isBP => _selected == 'blood_pressure';
|
||||
@@ -433,19 +441,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildMetricTabs(),
|
||||
_buildMetricOverview(),
|
||||
const SizedBox(height: 16),
|
||||
if (_latestRecord != null) ...[
|
||||
_buildLatestSummary(),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
_buildPeriodSelector(),
|
||||
const SizedBox(height: 12),
|
||||
_buildChart(),
|
||||
if (_chartRecords.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildStatistics(),
|
||||
],
|
||||
_buildTrendInsightPanel(),
|
||||
const SizedBox(height: 24),
|
||||
_buildHistory(),
|
||||
const SizedBox(height: 80),
|
||||
@@ -456,47 +454,57 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ---- 指标选择标签 ----
|
||||
Widget _buildMetricTabs() {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
// ---- 顶部指标概览,同时作为切换入口 ----
|
||||
Widget _buildMetricOverview() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: AppColors.divider.withValues(alpha: 0.7)),
|
||||
),
|
||||
child: Row(
|
||||
children: _metrics.map((m) {
|
||||
final key = m['key'] as String;
|
||||
final color = m['color'] as Color;
|
||||
final sel = _selected == key;
|
||||
return GestureDetector(
|
||||
onTap: () => _switchMetric(key),
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(right: 8),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? AppColors.primarySoft : Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
border: Border.all(
|
||||
color: sel ? AppColors.primary : AppColors.borderLight,
|
||||
return Expanded(
|
||||
child: InkWell(
|
||||
onTap: () => _switchMetric(key),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
height: 44,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? color : Colors.transparent,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
m['icon'] as IconData,
|
||||
size: 17,
|
||||
color: sel ? AppColors.primaryDark : color,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
m['label'] as String,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: sel
|
||||
? AppColors.primaryDark
|
||||
: AppColors.textSecondary,
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
m['icon'] as IconData,
|
||||
size: 16,
|
||||
color: sel ? Colors.white : color,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
m['label'] as String,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: sel ? Colors.white : AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -510,95 +518,167 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final date = record['date'] as DateTime;
|
||||
final status = trendStatusLabel(record, _selected);
|
||||
final abnormal = status.isNotEmpty;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: AppSpacing.panel,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Row(
|
||||
final art = _latestCardArt[_selected] ?? _latestCardArt['heart_rate']!;
|
||||
return SizedBox(
|
||||
height: 214,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withValues(alpha: 0.10),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
Positioned(
|
||||
left: 0,
|
||||
right: -48,
|
||||
top: -12,
|
||||
bottom: -12,
|
||||
child: Image.asset(
|
||||
art,
|
||||
fit: BoxFit.cover,
|
||||
alignment: Alignment.centerRight,
|
||||
),
|
||||
child: Icon(_getMetricIcon(_selected), color: _color, size: 25),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.96),
|
||||
Colors.white.withValues(alpha: 0.72),
|
||||
Colors.white.withValues(alpha: 0.04),
|
||||
],
|
||||
stops: const [0, 0.42, 0.78],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(6, 8, 178, 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'最新$_name',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
spacing: 6,
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
_displayValue(record),
|
||||
style: const TextStyle(
|
||||
fontSize: 32,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.05,
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withValues(alpha: 0.12),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(
|
||||
_getMetricIcon(_selected),
|
||||
color: _color,
|
||||
size: 23,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 3),
|
||||
child: Text(
|
||||
_unit,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Flexible(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'最新$_name',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
_formatRecordTime(date),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.end,
|
||||
spacing: 7,
|
||||
runSpacing: 4,
|
||||
children: [
|
||||
Text(
|
||||
_displayValue(record),
|
||||
style: const TextStyle(
|
||||
fontSize: 44,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 0.98,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_StatusBadge(
|
||||
label: abnormal ? status : '正常',
|
||||
abnormal: abnormal,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
abnormal ? status : '正常',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: abnormal ? AppColors.errorText : AppColors.successText,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_formatRecordTime(date),
|
||||
style: const TextStyle(fontSize: 13, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
Positioned(
|
||||
left: 6,
|
||||
right: 178,
|
||||
bottom: 8,
|
||||
child: _buildPeriodSelector(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTrendInsightPanel() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.cardBorder,
|
||||
boxShadow: AppShadows.panel,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
if (_latestRecord != null) ...[
|
||||
_buildLatestSummary(),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
if (_latestRecord == null) ...[
|
||||
_buildPeriodSelector(),
|
||||
const SizedBox(height: 14),
|
||||
],
|
||||
_buildChart(),
|
||||
if (_chartRecords.isNotEmpty) ...[
|
||||
const SizedBox(height: 4),
|
||||
_buildStatistics(),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPeriodSelector() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(3),
|
||||
constraints: const BoxConstraints(maxWidth: 310),
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
color: _color.withValues(alpha: 0.06),
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
border: Border.all(color: _color.withValues(alpha: 0.14)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -611,21 +691,21 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
child: Container(
|
||||
height: 38,
|
||||
height: 30,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: _periodDays == days
|
||||
? AppColors.primaryLight
|
||||
? _color.withValues(alpha: 0.92)
|
||||
: Colors.transparent,
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
'$days天',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: _periodDays == days
|
||||
? AppColors.primaryDark
|
||||
? Colors.white
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
@@ -645,6 +725,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
.toList();
|
||||
String average;
|
||||
String highest;
|
||||
String lowest;
|
||||
if (_isBP) {
|
||||
final systolic = records
|
||||
.map((record) => (record['systolic'] as num?)?.toDouble())
|
||||
@@ -657,29 +738,42 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
average = systolic.isEmpty || diastolic.isEmpty
|
||||
? '--'
|
||||
: '${(systolic.reduce((a, b) => a + b) / systolic.length).round()}/${(diastolic.reduce((a, b) => a + b) / diastolic.length).round()}';
|
||||
final highestRecord = records
|
||||
.where((record) {
|
||||
return record['systolic'] is num && record['diastolic'] is num;
|
||||
})
|
||||
.fold<Map<String, dynamic>?>(null, (current, record) {
|
||||
if (current == null) return record;
|
||||
final currentValue = (current['systolic'] as num).toDouble();
|
||||
final recordValue = (record['systolic'] as num).toDouble();
|
||||
return recordValue > currentValue ? record : current;
|
||||
});
|
||||
final bpRecords = records.where((record) {
|
||||
return record['systolic'] is num && record['diastolic'] is num;
|
||||
});
|
||||
final highestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
|
||||
current,
|
||||
record,
|
||||
) {
|
||||
if (current == null) return record;
|
||||
final currentValue = (current['systolic'] as num).toDouble();
|
||||
final recordValue = (record['systolic'] as num).toDouble();
|
||||
return recordValue > currentValue ? record : current;
|
||||
});
|
||||
final lowestRecord = bpRecords.fold<Map<String, dynamic>?>(null, (
|
||||
current,
|
||||
record,
|
||||
) {
|
||||
if (current == null) return record;
|
||||
final currentValue = (current['systolic'] as num).toDouble();
|
||||
final recordValue = (record['systolic'] as num).toDouble();
|
||||
return recordValue < currentValue ? record : current;
|
||||
});
|
||||
highest = highestRecord == null ? '--' : _displayValue(highestRecord);
|
||||
lowest = lowestRecord == null ? '--' : _displayValue(lowestRecord);
|
||||
} else {
|
||||
average = values.isEmpty
|
||||
? '--'
|
||||
: formatTrendNumber(values.reduce((a, b) => a + b) / values.length);
|
||||
highest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(max));
|
||||
lowest = values.isEmpty ? '--' : formatTrendNumber(values.reduce(min));
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
color: _color.withValues(alpha: 0.045),
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -687,7 +781,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
|
||||
_StatisticItem(label: '最高', value: highest, unit: _unit),
|
||||
const SizedBox(height: 46, child: VerticalDivider(width: 1)),
|
||||
_StatisticItem(label: '记录', value: '${records.length}', unit: '次'),
|
||||
_StatisticItem(label: '最低', value: lowest, unit: _unit),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -796,16 +890,30 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}
|
||||
|
||||
// X 轴刻度间隔:日标签短,可以排密一点
|
||||
final xInterval = spots.length > 60
|
||||
? 5.0
|
||||
: spots.length > 30
|
||||
? 2.0
|
||||
: 1.0;
|
||||
const xInterval = 1.0;
|
||||
final dayStartIndices = <int>[];
|
||||
for (var i = 0; i < records.length; i++) {
|
||||
if (i == 0 ||
|
||||
!_isSameChartDay(
|
||||
records[i - 1]['date'] as DateTime,
|
||||
records[i]['date'] as DateTime,
|
||||
)) {
|
||||
dayStartIndices.add(i);
|
||||
}
|
||||
}
|
||||
final dayLabelStep = dayStartIndices.length > 7
|
||||
? (dayStartIndices.length / 7).ceil()
|
||||
: 1;
|
||||
final visibleDayLabels = <int>{
|
||||
for (var i = 0; i < dayStartIndices.length; i += dayLabelStep)
|
||||
dayStartIndices[i],
|
||||
if (dayStartIndices.isNotEmpty) dayStartIndices.last,
|
||||
};
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
padding: const EdgeInsets.fromLTRB(4, 10, 12, 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
color: Colors.transparent,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Column(
|
||||
@@ -853,7 +961,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final chartWidth = constraints.maxWidth;
|
||||
final paintWidth = chartWidth - 40.0; // 减左侧轴
|
||||
final paintWidth = chartWidth - 32.0; // 减左侧轴
|
||||
final paintHeight = 200.0 - 36.0; // 减底部轴
|
||||
// 计算选中点的像素位置
|
||||
Offset? tooltipPos;
|
||||
@@ -868,7 +976,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
: (r['value'] as num?)?.toDouble();
|
||||
if (v != null && spots.length > 1) {
|
||||
final px =
|
||||
40.0 +
|
||||
32.0 +
|
||||
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
||||
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||
final d = r['date'] as DateTime;
|
||||
@@ -904,7 +1012,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
reservedSize: 32,
|
||||
interval: yStep,
|
||||
getTitlesWidget: (v, meta) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
@@ -929,6 +1037,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
if (idx < 0 || idx >= records.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
if (!visibleDayLabels.contains(idx)) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: Text(
|
||||
@@ -953,9 +1064,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.28,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 30,
|
||||
@@ -978,14 +1090,25 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_color.withValues(alpha: 0.18),
|
||||
_color.withValues(alpha: 0.02),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_isBP && diastolicSpots.isNotEmpty)
|
||||
LineChartBarData(
|
||||
spots: diastolicSpots,
|
||||
isCurved: false,
|
||||
isCurved: true,
|
||||
curveSmoothness: 0.28,
|
||||
color: _TrendColors.diastolic,
|
||||
barWidth: 2.5,
|
||||
barWidth: 3,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: diastolicSpots.length <= 30,
|
||||
@@ -999,7 +1122,12 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
strokeColor: _TrendColors.diastolic,
|
||||
),
|
||||
),
|
||||
belowBarData: BarAreaData(show: false),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
color: _TrendColors.diastolic.withValues(
|
||||
alpha: 0.05,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
@@ -1087,18 +1215,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
|
||||
String _chartAxisLabel(List<Map<String, dynamic>> records, int index) {
|
||||
final date = records[index]['date'] as DateTime;
|
||||
final sameDayCount = records.where((record) {
|
||||
final other = record['date'] as DateTime;
|
||||
return other.year == date.year &&
|
||||
other.month == date.month &&
|
||||
other.day == date.day;
|
||||
}).length;
|
||||
if (sameDayCount > 1) {
|
||||
return '${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
return '${date.month}-${date.day}';
|
||||
}
|
||||
|
||||
bool _isSameChartDay(DateTime first, DateTime second) {
|
||||
return first.year == second.year &&
|
||||
first.month == second.month &&
|
||||
first.day == second.day;
|
||||
}
|
||||
|
||||
// ---- 历史记录列表 ----
|
||||
Widget _buildHistory() {
|
||||
if (_filtered.isEmpty) return const SizedBox.shrink();
|
||||
@@ -1113,7 +1238,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'最近${min(30, _filtered.length)}条',
|
||||
'共${_filtered.length}条',
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
@@ -1127,6 +1252,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: AppShadows.soft,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -1230,40 +1356,47 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_formatRecordTime(date),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.end,
|
||||
children: [
|
||||
Text(
|
||||
'$display $_unit',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: abnormal
|
||||
? AppColors.errorText
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (status.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
status,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.errorText,
|
||||
fontWeight: FontWeight.w600,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Text(
|
||||
_formatRecordTime(date),
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_StatusBadge(
|
||||
label: abnormal ? status : '正常',
|
||||
abnormal: abnormal,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
width: 116,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
'$display $_unit',
|
||||
maxLines: 1,
|
||||
style: const TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -1359,6 +1492,43 @@ class _StatisticItem extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusBadge extends StatelessWidget {
|
||||
final String label;
|
||||
final bool abnormal;
|
||||
|
||||
const _StatusBadge({required this.label, required this.abnormal});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final color = abnormal ? AppColors.warningText : AppColors.successText;
|
||||
final background = abnormal
|
||||
? AppColors.warningLight
|
||||
: AppColors.successLight;
|
||||
return Container(
|
||||
constraints: const BoxConstraints(maxWidth: 78),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: background,
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: color.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: color,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ChartLegend extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
@@ -124,7 +125,7 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
child: _StatCard(
|
||||
'待审核报告',
|
||||
'${stats['pendingReports'] ?? 0}',
|
||||
Icons.description,
|
||||
AppModuleVisuals.report.icon,
|
||||
const Color(0xFFF59E0B),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('reports');
|
||||
@@ -136,7 +137,7 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
child: _StatCard(
|
||||
'今日随访',
|
||||
'${stats['todayFollowUps'] ?? 0}',
|
||||
Icons.event_note,
|
||||
AppModuleVisuals.followup.icon,
|
||||
const Color(0xFFEF4444),
|
||||
() {
|
||||
ref.read(doctorPageProvider.notifier).set('followups');
|
||||
@@ -166,7 +167,7 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
// 待办:待审核报告
|
||||
_TodoSection(
|
||||
title: '待审核报告',
|
||||
icon: Icons.description_outlined,
|
||||
icon: AppModuleVisuals.report.icon,
|
||||
color: const Color(0xFFF59E0B),
|
||||
items:
|
||||
(data?['pendingReports'] as List?)
|
||||
@@ -182,7 +183,7 @@ class DoctorDashboardPage extends ConsumerWidget {
|
||||
// 今日随访
|
||||
_TodoSection(
|
||||
title: '今日随访',
|
||||
icon: Icons.event_note_outlined,
|
||||
icon: AppModuleVisuals.followup.icon,
|
||||
color: const Color(0xFFEF4444),
|
||||
items:
|
||||
(data?['todayFollowUps'] as List?)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
@@ -92,18 +93,18 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
|
||||
),
|
||||
Expanded(
|
||||
child: items.isEmpty && refresh.isLoading
|
||||
? const BackofficeLoadingState(message: '正在加载随访')
|
||||
? BackofficeLoadingState(message: '正在加载随访')
|
||||
: items.isEmpty && refresh.hasError
|
||||
? BackofficeErrorState(onRetry: () => ref.invalidate(_fupRefresh))
|
||||
: RefreshIndicator(
|
||||
onRefresh: () => ref.refresh(_fupRefresh.future),
|
||||
child: items.isEmpty
|
||||
? ListView(
|
||||
children: const [
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 360,
|
||||
child: BackofficeEmptyState(
|
||||
icon: Icons.event_note_outlined,
|
||||
icon: AppModuleVisuals.followup.icon,
|
||||
title: '暂无随访',
|
||||
description: '新建的复查随访会显示在这里',
|
||||
),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../utils/backoffice_formatters.dart';
|
||||
@@ -250,7 +251,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: _LinkCard(
|
||||
'报告 (${reports.length})',
|
||||
Icons.description_outlined,
|
||||
AppModuleVisuals.report.icon,
|
||||
() {},
|
||||
),
|
||||
),
|
||||
@@ -258,7 +259,7 @@ class DoctorPatientDetailPage extends ConsumerWidget {
|
||||
Expanded(
|
||||
child: _LinkCard(
|
||||
'随访 (${followUps.length})',
|
||||
Icons.event_note_outlined,
|
||||
AppModuleVisuals.followup.icon,
|
||||
() {},
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
@@ -116,8 +117,8 @@ class _DoctorReportDetailPageState
|
||||
onRetry: () => ref.invalidate(_reportDetailProvider(widget.id)),
|
||||
),
|
||||
data: (data) => data == null
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
? BackofficeEmptyState(
|
||||
icon: AppModuleVisuals.report.icon,
|
||||
title: '报告不存在',
|
||||
)
|
||||
: _buildBody(data),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/backoffice_refresh_providers.dart';
|
||||
@@ -71,8 +72,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
.where((item) => item['status'] == _filter)
|
||||
.toList();
|
||||
return items.isEmpty
|
||||
? const BackofficeEmptyState(
|
||||
icon: Icons.description_outlined,
|
||||
? BackofficeEmptyState(
|
||||
icon: AppModuleVisuals.report.icon,
|
||||
title: '暂无报告',
|
||||
description: '患者上传的待审核报告会显示在这里',
|
||||
)
|
||||
@@ -93,8 +94,8 @@ class _DoctorReportsPageState extends ConsumerState<DoctorReportsPage> {
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.description_outlined,
|
||||
child: Icon(
|
||||
AppModuleVisuals.report.icon,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -36,6 +36,7 @@ class _EnterpriseExercisePlanPageState
|
||||
try {
|
||||
await ref.read(exerciseServiceProvider).checkIn(itemId);
|
||||
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
|
||||
await ref.read(exercisePlansProvider.future);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
@@ -149,8 +150,6 @@ class _EnterpriseExercisePlanPageState
|
||||
_TodayExercisePanel(
|
||||
items: todayItems,
|
||||
completed: completedToday,
|
||||
busyItems: _busyItems,
|
||||
onCheckIn: _toggleCheckIn,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
for (final phase in CarePlanPhase.values)
|
||||
@@ -178,15 +177,8 @@ class _EnterpriseExercisePlanPageState
|
||||
class _TodayExercisePanel extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> items;
|
||||
final int completed;
|
||||
final Set<String> busyItems;
|
||||
final Future<void> Function(String) onCheckIn;
|
||||
|
||||
const _TodayExercisePanel({
|
||||
required this.items,
|
||||
required this.completed,
|
||||
required this.busyItems,
|
||||
required this.onCheckIn,
|
||||
});
|
||||
const _TodayExercisePanel({required this.items, required this.completed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -256,12 +248,6 @@ class _TodayExercisePanel extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (next != null)
|
||||
_CompactActionButton(
|
||||
busy: busyItems.contains(next['id']?.toString() ?? ''),
|
||||
completed: false,
|
||||
onPressed: () => onCheckIn(next['id']?.toString() ?? ''),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
@@ -384,9 +384,20 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
) => (label: label, visual: visual);
|
||||
|
||||
final module = switch (agent) {
|
||||
ActiveAgent.health => fromModule('记数据', AppModuleVisuals.health),
|
||||
ActiveAgent.health => (
|
||||
label: '记数据',
|
||||
visual: AppModuleVisual(
|
||||
module: AppModule.health,
|
||||
label: AppModuleVisuals.health.label,
|
||||
icon: AppModuleVisuals.healthOverviewIcon,
|
||||
color: AppModuleVisuals.health.color,
|
||||
lightColor: AppModuleVisuals.health.lightColor,
|
||||
borderColor: AppModuleVisuals.health.borderColor,
|
||||
gradient: AppModuleVisuals.health.gradient,
|
||||
),
|
||||
),
|
||||
ActiveAgent.diet => fromModule('拍饮食', AppModuleVisuals.diet),
|
||||
ActiveAgent.medication => fromModule('药提醒', AppModuleVisuals.medication),
|
||||
ActiveAgent.medication => fromModule('药管家', AppModuleVisuals.medication),
|
||||
ActiveAgent.report => fromModule('报告', AppModuleVisuals.report),
|
||||
ActiveAgent.exercise => fromModule('运动', AppModuleVisuals.exercise),
|
||||
_ => null,
|
||||
@@ -402,13 +413,13 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
|
||||
return switch (agent) {
|
||||
ActiveAgent.consultation => (
|
||||
label: 'AI对话',
|
||||
label: 'AI问诊',
|
||||
icon: LucideIcons.messageCircle,
|
||||
gradient: AppColors.doctorGradient,
|
||||
iconColor: Colors.white,
|
||||
),
|
||||
_ => (
|
||||
label: 'AI对话',
|
||||
label: 'AI问诊',
|
||||
icon: LucideIcons.messageCircle,
|
||||
gradient: AppColors.primaryGradient,
|
||||
iconColor: Colors.white,
|
||||
|
||||
@@ -705,7 +705,15 @@ class _NotificationVisual {
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
|
||||
case 'health':
|
||||
case 'health_record_reminder':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||
return _NotificationVisual(
|
||||
AppModuleVisuals.healthOverviewIcon,
|
||||
AppModuleVisuals.health.color,
|
||||
AppModuleVisuals.health.lightColor,
|
||||
AppModuleVisuals.health.gradient,
|
||||
Colors.white,
|
||||
false,
|
||||
AppModuleVisuals.health.label,
|
||||
);
|
||||
case 'report':
|
||||
return _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||
case 'medication':
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
@@ -7,12 +11,79 @@ import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../widgets/authenticated_network_image.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
class ProfilePage extends ConsumerStatefulWidget {
|
||||
const ProfilePage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ConsumerState<ProfilePage> createState() => _ProfilePageState();
|
||||
}
|
||||
|
||||
class _ProfilePageState extends ConsumerState<ProfilePage> {
|
||||
bool _uploadingAvatar = false;
|
||||
|
||||
Future<void> _changeAvatar() async {
|
||||
final source = await showModalBottomSheet<ImageSource>(
|
||||
context: context,
|
||||
builder: (sheetContext) => SafeArea(
|
||||
child: Wrap(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.photo_library_outlined),
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () => Navigator.pop(sheetContext, ImageSource.gallery),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.camera_alt_outlined),
|
||||
title: const Text('拍照上传'),
|
||||
onTap: () => Navigator.pop(sheetContext, ImageSource.camera),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (source == null || !mounted) return;
|
||||
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
maxWidth: 1200,
|
||||
);
|
||||
if (picked == null || !mounted) return;
|
||||
|
||||
setState(() => _uploadingAvatar = true);
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
final avatarUrl = await api.uploadFile(
|
||||
'/api/files/upload',
|
||||
File(picked.path),
|
||||
);
|
||||
if (avatarUrl == null) throw StateError('上传未返回头像地址');
|
||||
final updatedProfile = await ref
|
||||
.read(userServiceProvider)
|
||||
.updateProfile(avatarUrl: avatarUrl);
|
||||
final savedAvatarUrl = updatedProfile?['avatarUrl']?.toString();
|
||||
if (savedAvatarUrl == null || savedAvatarUrl != avatarUrl) {
|
||||
throw StateError('头像地址未保存到个人资料');
|
||||
}
|
||||
await ref.read(authProvider.notifier).applyProfile(updatedProfile!);
|
||||
if (mounted) {
|
||||
AppToast.show(context, '头像已更新', type: AppToastType.success);
|
||||
}
|
||||
} catch (_) {
|
||||
if (mounted) {
|
||||
AppToast.show(context, '头像上传失败,请稍后重试', type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _uploadingAvatar = false);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authProvider.select((state) => state.user));
|
||||
final name = user?.name?.trim().isNotEmpty == true ? user!.name! : '未设置昵称';
|
||||
final phone = user?.phone.trim().isNotEmpty == true
|
||||
@@ -36,8 +107,10 @@ class ProfilePage extends ConsumerWidget {
|
||||
name: name,
|
||||
phone: phone,
|
||||
avatarUrl: user?.avatarUrl,
|
||||
uploading: _uploadingAvatar,
|
||||
onChangeAvatar: _uploadingAvatar ? null : _changeAvatar,
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const SizedBox(height: 24),
|
||||
const _SectionTitle('资料管理'),
|
||||
const SizedBox(height: 9),
|
||||
_SettingsGroup(
|
||||
@@ -50,7 +123,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
onTap: () => pushRoute(ref, 'profileEdit'),
|
||||
),
|
||||
_ActionRow(
|
||||
icon: AppModuleVisuals.health.icon,
|
||||
icon: LucideIcons.folderHeart,
|
||||
iconColor: AppModuleVisuals.health.color,
|
||||
title: '健康档案',
|
||||
subtitle: '疾病、手术、过敏和生活习惯',
|
||||
@@ -58,19 +131,25 @@ class ProfilePage extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const _SectionTitle('账号操作'),
|
||||
const SizedBox(height: 9),
|
||||
_SettingsGroup(
|
||||
children: [
|
||||
_ActionRow(
|
||||
icon: Icons.logout_rounded,
|
||||
iconColor: AppColors.textSecondary,
|
||||
title: '退出登录',
|
||||
showChevron: false,
|
||||
onTap: () => _logout(context, ref),
|
||||
const SizedBox(height: 28),
|
||||
SizedBox(
|
||||
height: 52,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () => _logout(context, ref),
|
||||
icon: const Icon(Icons.logout_rounded),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.errorText,
|
||||
side: const BorderSide(color: Color(0xFFFECACA)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -108,59 +187,100 @@ class _AccountSummary extends StatelessWidget {
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? avatarUrl;
|
||||
final bool uploading;
|
||||
final VoidCallback? onChangeAvatar;
|
||||
|
||||
const _AccountSummary({
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.avatarUrl,
|
||||
required this.uploading,
|
||||
required this.onChangeAvatar,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 60,
|
||||
height: 60,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: AppModuleVisuals.health.lightColor,
|
||||
borderRadius: AppRadius.lgBorder,
|
||||
),
|
||||
child: avatarUrl?.isNotEmpty == true
|
||||
? Image.network(
|
||||
avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (context, error, stackTrace) =>
|
||||
const _AvatarFallback(),
|
||||
)
|
||||
: const _AvatarFallback(),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppColors.primary.withValues(alpha: 0.06),
|
||||
blurRadius: 20,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
const SizedBox(width: 15),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.summaryTitle.copyWith(fontSize: 20),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 92,
|
||||
height: 92,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: AppModuleVisuals.health.lightColor,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(phone, style: AppTextStyles.listSubtitle),
|
||||
const SizedBox(height: 5),
|
||||
const Text(
|
||||
'头像由账号系统统一管理',
|
||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
||||
child: avatarUrl?.isNotEmpty == true
|
||||
? AuthenticatedNetworkImage(
|
||||
imageUrl: avatarUrl!,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, _, _) => const _AvatarFallback(),
|
||||
)
|
||||
: const _AvatarFallback(),
|
||||
),
|
||||
Positioned(
|
||||
right: -2,
|
||||
bottom: -2,
|
||||
child: Material(
|
||||
color: AppColors.primary,
|
||||
shape: const CircleBorder(),
|
||||
child: InkWell(
|
||||
onTap: onChangeAvatar,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox(
|
||||
width: 34,
|
||||
height: 34,
|
||||
child: Center(
|
||||
child: uploading
|
||||
? const SizedBox(
|
||||
width: 17,
|
||||
height: 17,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: Colors.white,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.summaryTitle.copyWith(fontSize: 21),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(phone, style: AppTextStyles.listSubtitle),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
onPressed: onChangeAvatar,
|
||||
icon: const Icon(Icons.edit_outlined, size: 17),
|
||||
label: const Text('更换头像'),
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -169,19 +289,17 @@ class _AccountSummary extends StatelessWidget {
|
||||
|
||||
class _AvatarFallback extends StatelessWidget {
|
||||
const _AvatarFallback();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppModuleVisuals.health.color,
|
||||
size: 34,
|
||||
size: 48,
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||
@@ -199,7 +317,6 @@ class _SectionTitle extends StatelessWidget {
|
||||
class _SettingsGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _SettingsGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
decoration: BoxDecoration(
|
||||
@@ -226,18 +343,14 @@ class _ActionRow extends StatelessWidget {
|
||||
final Color iconColor;
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool showChevron;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionRow({
|
||||
required this.icon,
|
||||
required this.iconColor,
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
this.showChevron = true,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: Colors.transparent,
|
||||
@@ -264,7 +377,7 @@ class _ActionRow extends StatelessWidget {
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
@@ -277,11 +390,7 @@ class _ActionRow extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showChevron)
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const Icon(Icons.chevron_right_rounded, color: AppColors.textHint),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2467,7 +2467,7 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.event_note_outlined,
|
||||
AppModuleVisuals.followup.icon,
|
||||
size: 64,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
|
||||
133
health_app/lib/pages/settings/ai_consent_details_page.dart
Normal file
@@ -0,0 +1,133 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../providers/ai_consent_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
class AiConsentDetailsPage extends ConsumerWidget {
|
||||
const AiConsentDetailsPage({super.key});
|
||||
|
||||
Future<void> _revoke(BuildContext context, WidgetRef ref) async {
|
||||
final userId = ref.read(authProvider).user?.id;
|
||||
if (userId == null || userId.isEmpty) return;
|
||||
|
||||
final confirmed = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('撤回 AI 授权?'),
|
||||
content: const Text('撤回后将退出当前账号,重新使用 App 前需要再次完成 AI 数据授权。'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
child: const Text('撤回并退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirmed != true || !context.mounted) return;
|
||||
|
||||
await ref.read(aiConsentProvider.notifier).revoke(userId);
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final consent = ref.watch(aiConsentProvider);
|
||||
final canRevoke = consent.granted && ref.watch(authProvider).user != null;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => Navigator.of(context).maybePop(),
|
||||
),
|
||||
title: const Text(
|
||||
'AI 数据授权说明',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_section(
|
||||
'我们会处理什么数据',
|
||||
'当你使用 AI 对话、饮食识别或报告预整理功能时,可能会处理你主动输入的文字、健康数据、健康档案摘要、图片和 PDF 报告。',
|
||||
),
|
||||
_section(
|
||||
'发送给哪些服务商',
|
||||
'DeepSeek:用于生成 AI 对话和健康信息整理。\n\n通义千问视觉服务:用于识别你主动上传的饮食图片、报告图片或 PDF 内容。\n\nFastGPT:用于检索与问题相关的医学参考资料。',
|
||||
),
|
||||
_section(
|
||||
'使用目的',
|
||||
'上述数据仅用于提供 AI 对话、健康记录辅助、饮食图片识别、医学报告预整理和医学资料检索功能。AI 内容仅供健康管理参考,不能替代医生诊断或治疗。',
|
||||
),
|
||||
_section(
|
||||
'你的选择',
|
||||
'你可以在首次进入首页时选择是否授权。未授权时无法进入本 App 的主要服务。你也可以在设置中撤回授权;撤回后将退出当前账号,再次使用前需要重新授权。',
|
||||
),
|
||||
_section(
|
||||
'更多信息',
|
||||
'数据保存、删除、第三方服务保护措施和联系方式,请同时查看 App 内《隐私政策》和《第三方 SDK 共享清单》。',
|
||||
),
|
||||
if (canRevoke) ...[
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton(
|
||||
onPressed: () => _revoke(context, ref),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: BorderSide(
|
||||
color: AppColors.error.withValues(alpha: 0.4),
|
||||
),
|
||||
minimumSize: const Size.fromHeight(50),
|
||||
),
|
||||
child: const Text('撤回 AI 数据授权'),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _section(String title, String body) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 22),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title, style: AppTextStyles.sectionTitle),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
body,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.65,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,11 @@ class SettingsPage extends ConsumerWidget {
|
||||
children: [
|
||||
_SettingsGroup(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.auto_awesome,
|
||||
title: 'AI 数据授权',
|
||||
onTap: () => pushRoute(ref, 'aiConsentDetails'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.elderly_rounded,
|
||||
title: '长辈模式',
|
||||
@@ -53,11 +58,11 @@ class SettingsPage extends ConsumerWidget {
|
||||
),
|
||||
if (!Platform.isIOS)
|
||||
// iOS 审核版:蓝牙设备入口已移除
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.info,
|
||||
title: '关于小脉健康',
|
||||
|
||||
56
health_app/lib/providers/ai_consent_provider.dart
Normal file
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'auth_provider.dart' show localDbProvider;
|
||||
|
||||
const aiConsentVersion = 'ai-data-consent-v1';
|
||||
|
||||
class AiConsentState {
|
||||
final bool isLoading;
|
||||
final bool granted;
|
||||
final String? userId;
|
||||
|
||||
const AiConsentState({
|
||||
this.isLoading = true,
|
||||
this.granted = false,
|
||||
this.userId,
|
||||
});
|
||||
|
||||
AiConsentState copyWith({bool? isLoading, bool? granted, String? userId}) {
|
||||
return AiConsentState(
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
granted: granted ?? this.granted,
|
||||
userId: userId ?? this.userId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final aiConsentProvider = NotifierProvider<AiConsentNotifier, AiConsentState>(
|
||||
AiConsentNotifier.new,
|
||||
);
|
||||
|
||||
class AiConsentNotifier extends Notifier<AiConsentState> {
|
||||
@override
|
||||
AiConsentState build() => const AiConsentState();
|
||||
|
||||
String _key(String userId) => '$aiConsentVersion:$userId';
|
||||
|
||||
Future<void> load(String userId) async {
|
||||
state = AiConsentState(isLoading: true, userId: userId);
|
||||
final value = await ref.read(localDbProvider).read(_key(userId));
|
||||
state = AiConsentState(
|
||||
isLoading: false,
|
||||
granted: value == 'granted',
|
||||
userId: userId,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> grant(String userId) async {
|
||||
await ref.read(localDbProvider).write(_key(userId), 'granted');
|
||||
state = AiConsentState(isLoading: false, granted: true, userId: userId);
|
||||
}
|
||||
|
||||
Future<void> revoke(String userId) async {
|
||||
await ref.read(localDbProvider).delete(_key(userId));
|
||||
state = AiConsentState(isLoading: false, granted: false, userId: userId);
|
||||
}
|
||||
}
|
||||
@@ -156,6 +156,13 @@ class AuthNotifier extends Notifier<AuthState> {
|
||||
|
||||
Future<void> refreshProfile() => _loadProfile();
|
||||
|
||||
Future<void> applyProfile(Map<String, dynamic> profile) async {
|
||||
final user = _userFromMap(profile, fallback: state.user);
|
||||
state = AuthState(isLoggedIn: true, isLoading: false, user: user);
|
||||
await _cacheUser(user);
|
||||
_publishSessionIdentity(user);
|
||||
}
|
||||
|
||||
/// 发送验证码
|
||||
Future<({String? error, String? devCode})> sendSms(String phone) async {
|
||||
try {
|
||||
|
||||
@@ -6,6 +6,7 @@ import 'auth_provider.dart';
|
||||
import 'conversation_history_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import 'data_refresh_providers.dart';
|
||||
import '../core/api_client.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
enum MessageType { text, dataConfirm, agentWelcome, taskCard }
|
||||
@@ -104,6 +105,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
Timer? _agentTapLockTimer;
|
||||
bool _loadingConversation = false;
|
||||
int _generation = 0;
|
||||
final Set<String> _confirmingMessageIds = <String>{};
|
||||
|
||||
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||
@@ -117,20 +119,23 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||
Future<String?> confirmMessage(String id) async {
|
||||
if (!_confirmingMessageIds.add(id)) return null;
|
||||
final msgs = state.messages.toList();
|
||||
final i = msgs.indexWhere((m) => m.id == id);
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
if (msgs[i].isReadOnly) 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 {
|
||||
if (i < 0) return '确认卡片不存在';
|
||||
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
|
||||
|
||||
final original = msgs[i];
|
||||
final metadata = _cloneMetadata(original.metadata) ?? <String, dynamic>{};
|
||||
final rawIds = metadata['confirmationIds'];
|
||||
final confirmationIds = rawIds is List
|
||||
? rawIds.map((e) => e.toString()).where((e) => e.isNotEmpty).toList()
|
||||
: <String>[];
|
||||
if (confirmationIds.isEmpty) {
|
||||
return '确认信息已失效,请重新发送需要录入的内容';
|
||||
}
|
||||
|
||||
final api = ref.read(apiClientProvider);
|
||||
for (final confirmationId in confirmationIds.toList()) {
|
||||
final response = await api.post(
|
||||
@@ -141,19 +146,24 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
return body is Map ? body['message']?.toString() ?? '录入失败' : '录入失败';
|
||||
}
|
||||
confirmationIds.remove(confirmationId);
|
||||
msgs[i].metadata?['confirmationIds'] = confirmationIds.toList();
|
||||
metadata['confirmationIds'] = List<String>.from(confirmationIds);
|
||||
msgs[i] = _copyMessage(original, metadata: metadata);
|
||||
state = state.copyWith(messages: msgs);
|
||||
}
|
||||
} catch (e) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
}
|
||||
|
||||
msgs[i].confirmed = true;
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
|
||||
return null;
|
||||
msgs[i] = _copyMessage(original, metadata: metadata, confirmed: true);
|
||||
state = state.copyWith(messages: msgs);
|
||||
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
|
||||
ref.invalidate(latestHealthProvider);
|
||||
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
|
||||
return null;
|
||||
} on ApiException catch (e) {
|
||||
return e.message;
|
||||
} catch (_) {
|
||||
return '录入失败,请检查网络后重试';
|
||||
} finally {
|
||||
_confirmingMessageIds.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -280,7 +290,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
|
||||
// 先显示用户消息(本地显示图片路径)
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text.isNotEmpty ? text : '[图片]',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -349,7 +359,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
_resumeConversationFromHistory();
|
||||
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text.isNotEmpty ? text : '请帮我看看这份 PDF',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -407,7 +417,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
_resumeConversationFromHistory();
|
||||
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text,
|
||||
createdAt: DateTime.now(),
|
||||
@@ -428,7 +438,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
}) async {
|
||||
if (generation != _generation || !state.isStreaming) return;
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
id: '${DateTime.now().microsecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime.now(),
|
||||
@@ -563,6 +573,15 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
aiMsg.content += (j['data'] as String?) ?? '';
|
||||
state = state.copyWith(thinkingText: null);
|
||||
_update(aiMsg);
|
||||
case 'confirmation':
|
||||
aiMsg.type = _parseMessageType(j['type'] as String? ?? 'data_confirm');
|
||||
aiMsg.confirmed = false;
|
||||
if (j['metadata'] is Map) {
|
||||
aiMsg.metadata = _cloneMetadata(
|
||||
Map<String, dynamic>.from(j['metadata']),
|
||||
);
|
||||
}
|
||||
_update(aiMsg);
|
||||
case 'notice':
|
||||
state = state.copyWith(thinkingText: j['message'] as String?);
|
||||
case 'tool_result':
|
||||
@@ -620,6 +639,27 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
state = state.copyWith(messages: u);
|
||||
}
|
||||
|
||||
Map<String, dynamic>? _cloneMetadata(Map<String, dynamic>? metadata) {
|
||||
if (metadata == null) return null;
|
||||
return Map<String, dynamic>.from(jsonDecode(jsonEncode(metadata)) as Map);
|
||||
}
|
||||
|
||||
ChatMessage _copyMessage(
|
||||
ChatMessage source, {
|
||||
Map<String, dynamic>? metadata,
|
||||
bool? confirmed,
|
||||
}) {
|
||||
return ChatMessage(
|
||||
id: source.id,
|
||||
role: source.role,
|
||||
content: source.content,
|
||||
createdAt: source.createdAt,
|
||||
type: source.type,
|
||||
metadata: metadata ?? _cloneMetadata(source.metadata),
|
||||
confirmed: confirmed ?? source.confirmed,
|
||||
);
|
||||
}
|
||||
|
||||
void _done(ChatMessage m) {
|
||||
final u = state.messages.toList();
|
||||
final content = m.content.trim();
|
||||
|
||||
@@ -19,15 +19,23 @@ class UserService {
|
||||
return res.data['data'];
|
||||
}
|
||||
|
||||
Future<void> updateProfile({
|
||||
Future<Map<String, dynamic>?> updateProfile({
|
||||
String? name,
|
||||
String? gender,
|
||||
String? birthDate,
|
||||
String? avatarUrl,
|
||||
}) async {
|
||||
await _api.put(
|
||||
final response = await _api.put(
|
||||
'/api/user/profile',
|
||||
data: {'name': name, 'gender': gender, 'birthDate': birthDate},
|
||||
data: {
|
||||
'name': name,
|
||||
'gender': gender,
|
||||
'birthDate': birthDate,
|
||||
'avatarUrl': avatarUrl,
|
||||
},
|
||||
);
|
||||
final data = response.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getHealthArchive() async {
|
||||
|
||||
126
health_app/lib/widgets/ai_consent_gate.dart
Normal file
@@ -0,0 +1,126 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_design_tokens.dart';
|
||||
import '../providers/ai_consent_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../pages/settings/ai_consent_details_page.dart';
|
||||
|
||||
class AiConsentGate extends ConsumerStatefulWidget {
|
||||
final Widget child;
|
||||
const AiConsentGate({super.key, required this.child});
|
||||
|
||||
@override
|
||||
ConsumerState<AiConsentGate> createState() => _AiConsentGateState();
|
||||
}
|
||||
|
||||
class _AiConsentGateState extends ConsumerState<AiConsentGate> {
|
||||
String? _loadedUserId;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final user = ref.watch(authProvider).user;
|
||||
if (user == null || user.id.isEmpty) return widget.child;
|
||||
|
||||
if (_loadedUserId != user.id) {
|
||||
_loadedUserId = user.id;
|
||||
Future<void>.microtask(
|
||||
() => ref.read(aiConsentProvider.notifier).load(user.id),
|
||||
);
|
||||
}
|
||||
|
||||
final consent = ref.watch(aiConsentProvider);
|
||||
if (consent.userId != user.id || consent.isLoading) {
|
||||
return const Scaffold(backgroundColor: Colors.white);
|
||||
}
|
||||
if (consent.granted) return widget.child;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: AppColors.background,
|
||||
body: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _ConsentCard(userId: user.id),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ConsentCard extends ConsumerWidget {
|
||||
final String userId;
|
||||
const _ConsentCard({required this.userId});
|
||||
|
||||
Future<void> _grant(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(aiConsentProvider.notifier).grant(userId);
|
||||
}
|
||||
|
||||
Future<void> _decline(BuildContext context, WidgetRef ref) async {
|
||||
await ref.read(authProvider.notifier).logout();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Card(
|
||||
elevation: 0,
|
||||
color: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(22, 24, 22, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.auto_awesome, size: 40, color: AppColors.primary),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'AI 健康服务授权',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 21, fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'小脉健康的核心功能使用第三方 AI 服务。经你同意后,你主动输入的健康信息、对话内容、图片和报告可能会发送给相关 AI 服务,用于健康记录、饮食识别、报告整理和 AI 回复。',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
height: 1.6,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const Text(
|
||||
'AI 生成内容仅供健康管理参考,不能替代医生诊断或治疗。',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute<void>(
|
||||
builder: (_) => const AiConsentDetailsPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: const Text('查看《AI 数据处理说明》'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton(
|
||||
onPressed: () => _grant(context, ref),
|
||||
child: const Text('同意并继续'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
OutlinedButton(
|
||||
onPressed: () => _decline(context, ref),
|
||||
child: const Text('不同意并退出'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/api_client.dart' show baseUrl;
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
const int _maxProtectedImageCacheEntries = 32;
|
||||
final Map<String, Uint8List> _protectedImageBytesCache = {};
|
||||
|
||||
String protectedMediaUrl(String value) {
|
||||
final trimmed = value.trim();
|
||||
if (trimmed.isEmpty) return trimmed;
|
||||
@@ -58,6 +61,7 @@ class _AuthenticatedNetworkImageState
|
||||
extends ConsumerState<AuthenticatedNetworkImage> {
|
||||
late String _resolvedUrl;
|
||||
Future<Uint8List>? _bytes;
|
||||
Uint8List? _cachedBytes;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -73,11 +77,21 @@ class _AuthenticatedNetworkImageState
|
||||
|
||||
void _configure() {
|
||||
_resolvedUrl = protectedMediaUrl(widget.imageUrl);
|
||||
_bytes = mediaRequiresAuthentication(_resolvedUrl)
|
||||
? _loadProtectedBytes(_resolvedUrl)
|
||||
_cachedBytes = _protectedImageBytesCache[_resolvedUrl];
|
||||
_bytes = mediaRequiresAuthentication(_resolvedUrl) && _cachedBytes == null
|
||||
? _loadAndCacheProtectedBytes(_resolvedUrl)
|
||||
: null;
|
||||
}
|
||||
|
||||
Future<Uint8List> _loadAndCacheProtectedBytes(String url) async {
|
||||
final data = await _loadProtectedBytes(url);
|
||||
if (_protectedImageBytesCache.length >= _maxProtectedImageCacheEntries) {
|
||||
_protectedImageBytesCache.remove(_protectedImageBytesCache.keys.first);
|
||||
}
|
||||
_protectedImageBytesCache[url] = data;
|
||||
return data;
|
||||
}
|
||||
|
||||
Future<Uint8List> _loadProtectedBytes(String url) async {
|
||||
final response = await ref
|
||||
.read(apiClientProvider)
|
||||
@@ -91,6 +105,18 @@ class _AuthenticatedNetworkImageState
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cachedBytes = _cachedBytes;
|
||||
if (cachedBytes != null) {
|
||||
return Image.memory(
|
||||
cachedBytes,
|
||||
fit: widget.fit,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
);
|
||||
}
|
||||
|
||||
final bytes = _bytes;
|
||||
if (bytes == null) {
|
||||
return Image.network(
|
||||
@@ -98,6 +124,7 @@ class _AuthenticatedNetworkImageState
|
||||
fit: widget.fit,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
gaplessPlayback: true,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
);
|
||||
@@ -123,6 +150,7 @@ class _AuthenticatedNetworkImageState
|
||||
fit: widget.fit,
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
gaplessPlayback: true,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_module_visuals.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
import '../pages/doctor/doctor_home_page.dart' show doctorPageProvider;
|
||||
@@ -64,7 +65,7 @@ class DoctorDrawer extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.description_outlined,
|
||||
icon: AppModuleVisuals.report.icon,
|
||||
label: '报告审核',
|
||||
selected: currentPage == 'reports',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
@@ -73,7 +74,7 @@ class DoctorDrawer extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
_DrawerItem(
|
||||
icon: Icons.event_note_outlined,
|
||||
icon: AppModuleVisuals.followup.icon,
|
||||
label: '复查随访',
|
||||
selected: currentPage == 'followups',
|
||||
onTap: () => runAfterDrawerClose(
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:io';
|
||||
import 'dart:ui' as ui;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
@@ -11,6 +12,7 @@ import '../providers/auth_provider.dart';
|
||||
import '../providers/chat_provider.dart';
|
||||
import '../providers/conversation_history_provider.dart';
|
||||
import 'app_toast.dart';
|
||||
import 'authenticated_network_image.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
import 'drawer_shell.dart';
|
||||
|
||||
@@ -137,9 +139,7 @@ class _AccountHeader extends StatelessWidget {
|
||||
width: 66,
|
||||
height: 66,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7C5CFF).withValues(alpha: 0.16),
|
||||
@@ -148,10 +148,33 @@ class _AccountHeader extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Color(0xFF94A3B8),
|
||||
size: 34,
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
ClipOval(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: ColoredBox(
|
||||
color: const Color(0xFFF1F5F9),
|
||||
child: user?.avatarUrl?.toString().isNotEmpty == true
|
||||
? AuthenticatedNetworkImage(
|
||||
imageUrl: user.avatarUrl.toString(),
|
||||
fit: BoxFit.cover,
|
||||
width: 66,
|
||||
height: 66,
|
||||
errorBuilder: (_, _, _) => const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Color(0xFF94A3B8),
|
||||
size: 34,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Color(0xFF94A3B8),
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -209,11 +232,6 @@ class _HealthDashboard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return _Panel(
|
||||
title: '健康仪表盘',
|
||||
trailing: _TextAction(
|
||||
label: '详情',
|
||||
light: true,
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
),
|
||||
titleColor: Colors.white,
|
||||
backgroundPainter: const _HealthDashboardBackgroundPainter(),
|
||||
child: latestHealth.when(
|
||||
@@ -310,55 +328,82 @@ class _MetricTile extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final elderMode = ElderModeScope.enabledOf(context);
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
final height = elderMode ? 112.0 : 100.0;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Container(
|
||||
height: elderMode ? 112 : 100,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.22),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.40)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
info.value,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
child: BackdropFilter(
|
||||
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
height: height,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.62),
|
||||
Colors.white.withValues(alpha: 0.42),
|
||||
const Color(0xFFD9FCFF).withValues(alpha: 0.34),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Colors.white.withValues(alpha: 0.82),
|
||||
width: 1.2,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0369A1).withValues(alpha: 0.10),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 32,
|
||||
child: FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
info.value,
|
||||
maxLines: 1,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (info.unit != null)
|
||||
Text(
|
||||
info.unit!,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xE8FFFFFF),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
info.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (info.unit != null)
|
||||
Text(
|
||||
info.unit!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xE0FFFFFF),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
info.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -372,7 +417,7 @@ class _NavigationSection extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final elderMode = ElderModeScope.enabledOf(context);
|
||||
final allItems = <_NavItem>[
|
||||
final items = [
|
||||
_NavItem(
|
||||
icon: LucideIcons.folderHeart,
|
||||
title: '档案',
|
||||
@@ -422,9 +467,6 @@ class _NavigationSection extends StatelessWidget {
|
||||
colors: AppColors.deviceGradient.colors,
|
||||
),
|
||||
];
|
||||
final items = Platform.isIOS
|
||||
? allItems.where((item) => item.route != 'devices').toList()
|
||||
: allItems;
|
||||
|
||||
return _LightSection(
|
||||
title: '常用功能',
|
||||
@@ -631,13 +673,11 @@ class _LightSection extends StatelessWidget {
|
||||
class _Panel extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
final Widget? trailing;
|
||||
final CustomPainter? backgroundPainter;
|
||||
final Color titleColor;
|
||||
const _Panel({
|
||||
required this.title,
|
||||
required this.child,
|
||||
this.trailing,
|
||||
this.backgroundPainter,
|
||||
this.titleColor = AppColors.textPrimary,
|
||||
});
|
||||
@@ -680,7 +720,6 @@ class _Panel extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
?trailing,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
@@ -703,54 +742,49 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
|
||||
..shader = const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF00C6FB), Color(0xFF005BEA)],
|
||||
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||
).createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
|
||||
final glow = Paint()
|
||||
..shader =
|
||||
RadialGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.32),
|
||||
Colors.white.withValues(alpha: 0),
|
||||
],
|
||||
).createShader(
|
||||
Rect.fromCircle(
|
||||
center: Offset(size.width * 0.12, size.height * 0.05),
|
||||
radius: size.width * 0.7,
|
||||
),
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width * 0.12, size.height * 0.05),
|
||||
size.width * 0.7,
|
||||
glow,
|
||||
);
|
||||
|
||||
final highlight = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.20)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2;
|
||||
canvas.drawLine(
|
||||
Offset(size.width * 0.52, -12),
|
||||
Offset(size.width * 0.16, size.height + 18),
|
||||
highlight,
|
||||
);
|
||||
canvas.drawLine(
|
||||
Offset(size.width * 0.96, -10),
|
||||
Offset(size.width * 0.58, size.height + 22),
|
||||
highlight,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HealthDashboardBackgroundPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _TextAction extends StatelessWidget {
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool light;
|
||||
const _TextAction({
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.light = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 11, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: light
|
||||
? Colors.white.withValues(alpha: 0.24)
|
||||
: const Color(0xFFEDE9FE),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: light
|
||||
? Border.all(color: Colors.white.withValues(alpha: 0.34))
|
||||
: null,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: light ? Colors.white : Color(0xFF6D28D9),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _IconButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final VoidCallback onTap;
|
||||
|
||||
@@ -53,10 +53,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
version: "1.4.0"
|
||||
cli_config:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -561,18 +561,18 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.19"
|
||||
version: "0.12.17"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.13.0"
|
||||
version: "0.11.1"
|
||||
message_pack_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -585,10 +585,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.18.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@@ -1038,26 +1038,26 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test
|
||||
sha256: "8d9ceddbab833f180fbefed08afa76d7c03513dfdba87ffcec2718b02bbcbf20"
|
||||
sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.31.0"
|
||||
version: "1.26.3"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e"
|
||||
sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.11"
|
||||
version: "0.7.7"
|
||||
test_core:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_core
|
||||
sha256: "1991d4cfe85d5043241acac92962c3977c8d2f2add1ee73130c7b286417d1d34"
|
||||
sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.6.17"
|
||||
version: "0.6.12"
|
||||
theme_extensions_builder_annotation:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||