feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新

后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
This commit is contained in:
MingNian
2026-07-28 15:03:38 +08:00
parent 25a4078688
commit b4bc15b9b9
88 changed files with 5527 additions and 1240 deletions

View File

@@ -8,9 +8,11 @@ import 'core/app_theme.dart';
import 'core/elder_mode_scope.dart';
import 'core/navigation_provider.dart';
import 'pages/splash_page.dart';
import 'providers/ai_consent_provider.dart';
import 'providers/auth_provider.dart';
import 'providers/data_providers.dart';
import 'providers/elder_mode_provider.dart';
import 'widgets/ai_consent_gate.dart';
/// 健康管家 App 根组件
class HealthApp extends ConsumerWidget {
@@ -51,7 +53,7 @@ class HealthApp extends ConsumerWidget {
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
child: AiConsentGate(child: _BootGate(child: child!)),
),
),
),
@@ -71,6 +73,15 @@ final appReadyProvider = Provider<bool>((ref) {
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User';
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
final consent = ref.watch(aiConsentProvider);
if (consent.userId == auth.user?.id &&
!consent.isLoading &&
!consent.granted) {
return true;
}
if (currentRoute == 'aiConsentDetails' || currentRoute == 'staticText') {
return true;
}
if (!ref.watch(elderModeProvider).isLoaded) return false;
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
final health = ref.watch(latestHealthProvider);

View File

@@ -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://10.4.225.209:5000',
);
class ApiException implements Exception {

View File

@@ -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);
}

View File

@@ -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';
@@ -132,6 +133,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':

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../core/app_module_visuals.dart';
import 'bp_reading.dart';
enum BleDeviceType { bloodPressure, glucose, weightScale, pulseOximeter }
@@ -43,10 +44,10 @@ extension BleDeviceTypeX on BleDeviceType {
};
IconData get icon => switch (this) {
BleDeviceType.bloodPressure => Icons.speed_rounded,
BleDeviceType.glucose => Icons.water_drop_rounded,
BleDeviceType.bloodPressure => HealthMetricVisuals.bloodPressureIcon,
BleDeviceType.glucose => HealthMetricVisuals.glucoseIcon,
BleDeviceType.weightScale => Icons.monitor_weight_outlined,
BleDeviceType.pulseOximeter => Icons.air_rounded,
BleDeviceType.pulseOximeter => HealthMetricVisuals.spo2Icon,
};
static BleDeviceType? fromCode(String? code) {

View File

@@ -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;

View File

@@ -162,10 +162,13 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
Future<bool> _ensureBlePermissions() async {
Map<Permission, PermissionStatus> statuses;
try {
statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final permissions = <Permission>[
if (Platform.isAndroid) Permission.bluetoothScan,
if (Platform.isAndroid) Permission.bluetoothConnect,
// Android 11 and below require location permission for BLE scans.
if (Platform.isAndroid) Permission.locationWhenInUse,
];
statuses = await permissions.request();
} catch (_) {
if (mounted) {
setState(() => _pageMessage = '无法读取蓝牙权限状态');

View File

@@ -27,14 +27,10 @@ class DeviceScanPage extends ConsumerStatefulWidget {
class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
with SingleTickerProviderStateMixin {
static const _visibleDeviceWindow = Duration(seconds: 12);
final _results = <ScanResult>[];
StreamSubscription<List<ScanResult>>? _scanSub;
Timer? _scanStopTimer;
Timer? _resultPruneTimer;
String? _connectingId;
DateTime? _scanStartedAt;
bool _scanning = false;
late final AnimationController _pulseCtrl;
@@ -59,7 +55,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
void dispose() {
_pulseCtrl.dispose();
_scanStopTimer?.cancel();
_resultPruneTimer?.cancel();
_scanSub?.cancel();
unawaited(FlutterBluePlus.stopScan());
unawaited(ref.read(healthBleServiceProvider).disconnect());
@@ -99,7 +94,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
try {
await FlutterBluePlus.stopScan();
} catch (_) {}
_scanStartedAt = DateTime.now();
await _scanSub?.cancel();
_scanSub = FlutterBluePlus.onScanResults.listen(_onScanResults);
await FlutterBluePlus.startScan(
@@ -112,38 +106,24 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
_scanStopTimer = Timer(const Duration(seconds: 30), () {
if (mounted) setState(() => _scanning = false);
});
_resultPruneTimer?.cancel();
_resultPruneTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (!mounted || _connectingId != null) return;
final pruned = _visibleResults(_results);
if (_sameResults(_results, pruned)) return;
setState(() {
_results
..clear()
..addAll(pruned);
});
});
}
void _onScanResults(List<ScanResult> list) {
if (!mounted || _connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null) return;
final boundDevices = ref.read(omronDeviceProvider).devices;
final supported = list.where((result) {
if (result.timeStamp.isBefore(scanStartedAt)) return false;
if (!result.advertisementData.connectable) return false;
if (DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
return false;
}
if (!HealthBleService.isSupportedHealthDevice(result)) return false;
final candidates = list.where((result) {
// Keep the result in the list first. Many health devices expose their
// standard service only after connection, not in the advertisement.
// Also avoid relying on ScanResult.timeStamp/connectable here: both can
// be stale or conservative while a device is in communication mode.
if (!_looksLikeHealthDevice(result)) return false;
return boundDevices.every(
(device) => device.id != result.device.remoteId.toString(),
);
});
final next = [..._results];
for (final result in supported) {
for (final result in candidates) {
final index = next.indexWhere(
(item) => item.device.remoteId == result.device.remoteId,
);
@@ -163,33 +143,32 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
List<ScanResult> _visibleResults(List<ScanResult> results) {
final visible =
results
.where(
(result) =>
DateTime.now().difference(result.timeStamp) <=
_visibleDeviceWindow,
)
.toList()
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
final visible = [...results]
..sort(
(a, b) => a.device.remoteId.toString().compareTo(
b.device.remoteId.toString(),
),
);
return visible;
}
bool _looksLikeHealthDevice(ScanResult result) {
if (HealthBleService.deviceTypeFromScan(result) != null) return true;
final name = [
result.advertisementData.advName,
result.device.platformName,
].join(' ').toUpperCase();
return name.contains('OMRON') ||
name.contains('HEM') ||
name.contains('BLESMART') ||
name.contains('J735') ||
name.contains('BPM') ||
name.contains('BP');
}
Future<void> _connectBindAndSync(ScanResult result) async {
final remoteId = result.device.remoteId.toString();
if (_connectingId != null) return;
final scanStartedAt = _scanStartedAt;
if (scanStartedAt == null ||
result.timeStamp.isBefore(scanStartedAt) ||
!result.advertisementData.connectable ||
DateTime.now().difference(result.timeStamp) > _visibleDeviceWindow) {
AppToast.show(context, '设备已离线,请重新进入通信状态', type: AppToastType.warning);
return;
}
if (ref.read(omronDeviceProvider).isBound &&
ref.read(omronDeviceProvider).findById(remoteId) != null) {
AppToast.show(context, '该设备已绑定', type: AppToastType.info);
@@ -348,10 +327,15 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
}
Future<bool> _ensureBlePermissions() async {
final statuses = await [
Permission.bluetoothScan,
Permission.bluetoothConnect,
].request();
final permissions = <Permission>[
if (Platform.isAndroid) Permission.bluetoothScan,
if (Platform.isAndroid) Permission.bluetoothConnect,
// Android 11 and below require a location runtime permission for BLE
// scanning. Requesting it here also keeps older devices from silently
// returning an empty scan stream.
if (Platform.isAndroid) Permission.locationWhenInUse,
];
final statuses = await permissions.request();
final granted = statuses.values.every((status) => status.isGranted);
if (granted) return true;
if (!mounted) return false;

View File

@@ -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?)

View File

@@ -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: '新建的复查随访会显示在这里',
),

View File

@@ -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,
() {},
),
),

View File

@@ -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),

View File

@@ -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,
),
),

View File

@@ -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),

View File

@@ -384,7 +384,18 @@ 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.report => fromModule('报告', AppModuleVisuals.report),
@@ -427,9 +438,9 @@ class _HomePageState extends ConsumerState<HomePage>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const SizedBox(height: 4),
_buildAgentBar(),
const SizedBox(height: 12),
const SizedBox(height: 5),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
@@ -479,13 +490,13 @@ class _HomePageState extends ConsumerState<HomePage>
chatProvider.select((state) => state.isStreaming),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Container(
padding: EdgeInsets.fromLTRB(
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
),
decoration: BoxDecoration(
color: Colors.white,
@@ -529,7 +540,7 @@ class _HomePageState extends ConsumerState<HomePage>
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 15 : 12,
vertical: elderMode ? 12 : 9,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -960,7 +971,7 @@ class _VoiceHoldSurface extends StatelessWidget {
return Container(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 14 : 11,
vertical: elderMode ? 12 : 9,
),
alignment: Alignment.center,
child: Text(

File diff suppressed because it is too large Load Diff

View File

@@ -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':

View File

@@ -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),
],
),
),

View File

@@ -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,
),

View File

@@ -0,0 +1,154 @@
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;
final revoked = await ref.read(aiConsentProvider.notifier).revoke(userId);
if (!revoked) {
if (context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('撤回授权失败,请稍后重试')));
}
return;
}
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,
),
),
centerTitle: true,
),
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: consent.isSaving
? null
: () => _revoke(context, ref),
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.error,
side: BorderSide(
color: AppColors.error.withValues(alpha: 0.4),
),
minimumSize: const Size.fromHeight(50),
),
child: consent.isSaving
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: AppColors.error,
),
)
: 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,
),
),
],
),
);
}
}

View File

@@ -68,6 +68,11 @@ class SettingsPage extends ConsumerWidget {
params: {'type': 'about'},
),
),
_SettingsTile(
icon: LucideIcons.bot,
title: 'AI 数据授权',
onTap: () => pushRoute(ref, 'aiConsentDetails'),
),
_SettingsTile(
icon: LucideIcons.shield,
title: '隐私协议',

View File

@@ -0,0 +1,93 @@
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 isSaving;
final bool granted;
final String? userId;
final String? error;
const AiConsentState({
this.isLoading = true,
this.isSaving = false,
this.granted = false,
this.userId,
this.error,
});
}
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);
try {
final value = await ref.read(localDbProvider).read(_key(userId));
state = AiConsentState(
isLoading: false,
granted: value == 'granted',
userId: userId,
);
} catch (_) {
state = AiConsentState(
isLoading: false,
userId: userId,
error: '授权状态加载失败,请重试',
);
}
}
Future<bool> grant(String userId) async {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).write(_key(userId), 'granted');
state = AiConsentState(isLoading: false, granted: true, userId: userId);
return true;
} catch (_) {
state = AiConsentState(
isLoading: false,
userId: userId,
error: '授权保存失败,请稍后重试',
);
return false;
}
}
Future<bool> revoke(String userId) async {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).delete(_key(userId));
state = AiConsentState(isLoading: false, granted: false, userId: userId);
return true;
} catch (_) {
state = AiConsentState(
isLoading: false,
granted: true,
userId: userId,
error: '撤回授权失败,请稍后重试',
);
return false;
}
}
}

View File

@@ -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 {

View File

@@ -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();

View File

@@ -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 {

View File

@@ -0,0 +1,337 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/navigation_provider.dart';
import '../providers/ai_consent_provider.dart';
import '../providers/auth_provider.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 || user.role != 'User') {
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);
final currentRoute = ref.watch(currentRouteProvider).name;
final isConsentInformationRoute =
currentRoute == 'aiConsentDetails' || currentRoute == 'staticText';
if (consent.userId != user.id || consent.isLoading) {
return const _ConsentLoadingPage();
}
if (consent.granted || isConsentInformationRoute) return widget.child;
return _ConsentPage(userId: user.id);
}
}
class _ConsentLoadingPage extends StatelessWidget {
const _ConsentLoadingPage();
@override
Widget build(BuildContext context) {
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: const SafeArea(
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 30,
height: 30,
child: CircularProgressIndicator(
strokeWidth: 3,
color: AppColors.primary,
),
),
SizedBox(height: 14),
Text(
'正在加载授权状态…',
style: TextStyle(
fontSize: 15,
color: AppColors.textSecondary,
),
),
],
),
),
),
),
);
}
}
class _ConsentPage extends ConsumerWidget {
final String userId;
const _ConsentPage({required this.userId});
Future<void> _grant(BuildContext context, WidgetRef ref) async {
final succeeded = await ref.read(aiConsentProvider.notifier).grant(userId);
if (!succeeded && context.mounted) {
ScaffoldMessenger.of(
context,
).showSnackBar(const SnackBar(content: Text('授权保存失败,请稍后重试')));
}
}
Future<void> _decline(WidgetRef ref) async {
await ref.read(authProvider.notifier).logout();
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final consent = ref.watch(aiConsentProvider);
return Scaffold(
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: Container(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: AppRadius.cardBorder,
border: Border.all(color: AppColors.primaryLight, width: 1),
boxShadow: AppShadows.panel,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
child: Image.asset(
'assets/branding/health_login_character_transparent.png',
height: 112,
fit: BoxFit.contain,
semanticLabel: '小脉健康智能助手',
),
),
const SizedBox(height: 2),
Align(
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 6,
),
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: AppRadius.pillBorder,
),
child: const Text(
'小脉健康 · AI 服务',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
const SizedBox(height: 14),
const Text(
'AI 健康服务授权',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 22,
height: 1.25,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
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,
),
),
if (consent.error != null) ...[
const SizedBox(height: 14),
_ConsentErrorBanner(
message: consent.error!,
onRetry: consent.isSaving
? null
: () => ref
.read(aiConsentProvider.notifier)
.load(userId),
),
],
const SizedBox(height: 8),
TextButton(
onPressed: consent.isSaving
? null
: () => pushRoute(ref, 'aiConsentDetails'),
child: const Text('查看《AI 数据处理说明》'),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: _GradientConsentButton(
loading: consent.isSaving,
onTap: consent.isSaving
? null
: () => _grant(context, ref),
),
),
const SizedBox(width: 12),
Expanded(
child: OutlinedButton(
onPressed: consent.isSaving
? null
: () => _decline(ref),
style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(50),
foregroundColor: AppColors.textSecondary,
side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder(
borderRadius: AppRadius.lgBorder,
),
),
child: const Text('不同意并退出'),
),
),
],
),
],
),
),
),
),
),
),
),
);
}
}
class _ConsentErrorBanner extends StatelessWidget {
final String message;
final VoidCallback? onRetry;
const _ConsentErrorBanner({required this.message, this.onRetry});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
decoration: BoxDecoration(
color: AppColors.errorLight,
borderRadius: AppRadius.mdBorder,
),
child: Row(
children: [
const Icon(
Icons.error_outline_rounded,
size: 20,
color: AppColors.errorText,
),
const SizedBox(width: 8),
Expanded(
child: Text(
message,
style: const TextStyle(fontSize: 13, color: AppColors.errorText),
),
),
if (onRetry != null)
TextButton(onPressed: onRetry, child: const Text('重试')),
],
),
);
}
}
class _GradientConsentButton extends StatelessWidget {
final bool loading;
final VoidCallback? onTap;
const _GradientConsentButton({required this.loading, required this.onTap});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: onTap != null,
label: loading ? '正在保存授权' : '同意并继续',
child: IgnorePointer(
ignoring: onTap == null,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 160),
opacity: onTap == null && !loading ? 0.55 : 1,
child: Container(
height: 50,
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: AppRadius.lgBorder,
boxShadow: AppColors.buttonShadow,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: AppRadius.lgBorder,
child: Center(
child: loading
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(
strokeWidth: 2.4,
color: Colors.white,
),
)
: const Text(
'同意并继续',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
),
),
),
),
),
),
);
}
}

View File

@@ -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,
);
},

View File

@@ -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(

View File

@@ -10,6 +10,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';
@@ -136,9 +137,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),
@@ -147,10 +146,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,
),
),
),
],
),
),
),
@@ -208,11 +230,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(
@@ -313,7 +330,7 @@ class _MetricTile extends StatelessWidget {
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Container(
height: elderMode ? 112 : 100,
height: elderMode ? 108 : 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
@@ -627,13 +644,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,
});
@@ -664,20 +679,13 @@ class _Panel extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: titleColor,
),
),
),
?trailing,
],
Text(
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: titleColor,
),
),
const SizedBox(height: 12),
child,
@@ -708,45 +716,6 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
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;