refactor: 死代码清理 + 趋势图重构 + 侧边栏交互优化 + 智能体卡片去延迟
- 死代码清理: 删除 AppCard/AppMenuItem/AppTabChip/AppButtons 等 4 个未使用 Widget 文件; 清理 common_widgets/enterprise_widgets/app_status_badge 中未使用 class; 移除 HealthService 等未使用方法; 后端移除 ExerciseService.CreateFromItemsAsync/HealthArchiveService.GetOrCreateAsync/MedicationAgentHandler 死分支/ChatRequest DTO - 趋势图: 直线折线 + 渐变填充 + 阴影; 去网格; X 轴日+月份; Y 轴整数刻度最多 4 个; 点击数据点/录入记录显示上方浮层; 选中点变实心 - 侧边栏: 对话记录改单选删除(灰底高亮); 操作栏浮层修复触摸问题; 常用功能/健康仪表盘间距收紧; _Panel 去外框 - 智能体: 欢迎卡片去掉 400ms 延迟; 胶囊去阴影 - 其他: api_client IP 适配; 多页面 UI 微调; 新增 chat_provider/prelaunch_guardrails 测试
This commit is contained in:
@@ -4,12 +4,30 @@ import 'package:fl_chart/fl_chart.dart';
|
||||
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/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/app_toast.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
const String defaultTrendMetricType = 'blood_pressure';
|
||||
|
||||
const Set<String> supportedTrendMetricTypes = {
|
||||
'blood_pressure',
|
||||
'heart_rate',
|
||||
'glucose',
|
||||
'spo2',
|
||||
'weight',
|
||||
};
|
||||
|
||||
String normalizeTrendMetricType(String? metricType) {
|
||||
if (metricType == null) return defaultTrendMetricType;
|
||||
return supportedTrendMetricTypes.contains(metricType)
|
||||
? metricType
|
||||
: defaultTrendMetricType;
|
||||
}
|
||||
|
||||
/// 健康概览趋势页 — 五大指标合到一个页面
|
||||
class TrendPage extends ConsumerStatefulWidget {
|
||||
final String? metricType; // 可选初始选中指标
|
||||
@@ -20,7 +38,7 @@ class TrendPage extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
String _selected = 'blood_pressure';
|
||||
String _selected = defaultTrendMetricType;
|
||||
List<Map<String, dynamic>> _allRecords = [];
|
||||
List<Map<String, dynamic>> _filtered = [];
|
||||
bool _loading = true;
|
||||
@@ -84,12 +102,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
String get _name => _names[_selected] ?? '';
|
||||
bool get _isBP => _selected == 'blood_pressure';
|
||||
Color get _color =>
|
||||
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
||||
_metrics.firstWhere(
|
||||
(m) => m['key'] == normalizeTrendMetricType(_selected),
|
||||
)['color']
|
||||
as Color;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (widget.metricType != null) _selected = widget.metricType!;
|
||||
_selected = normalizeTrendMetricType(widget.metricType);
|
||||
_loadAll();
|
||||
}
|
||||
|
||||
@@ -293,7 +314,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text('健康概览'),
|
||||
centerTitle: true,
|
||||
),
|
||||
@@ -431,8 +455,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}
|
||||
|
||||
// X 轴刻度间隔:日标签短,可以排密一点
|
||||
final xInterval =
|
||||
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
|
||||
final xInterval = spots.length > 60
|
||||
? 5.0
|
||||
: spots.length > 30
|
||||
? 2.0
|
||||
: 1.0;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||
@@ -490,10 +517,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
? (r['systolic'] as num?)?.toDouble()
|
||||
: (r['value'] as num?)?.toDouble();
|
||||
if (v != null && spots.length > 1) {
|
||||
final px = 40.0 +
|
||||
final px =
|
||||
40.0 +
|
||||
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
||||
final py =
|
||||
paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||
final d = r['date'] as DateTime;
|
||||
tooltipPos = Offset(px, py);
|
||||
tooltipText1 =
|
||||
@@ -513,191 +540,198 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
minY: minV,
|
||||
maxY: maxV,
|
||||
gridData: const FlGridData(show: false),
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: yStep,
|
||||
getTitlesWidget: (v, meta) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Text(
|
||||
v.toStringAsFixed(0),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 36,
|
||||
interval: xInterval,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||
final prevIdx = idx - xInterval.toInt();
|
||||
final showMonth = idx == 0 ||
|
||||
prevIdx < 0 ||
|
||||
(_filtered[prevIdx]['date'] as DateTime).month !=
|
||||
d.month;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${d.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showMonth) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${d.month}月',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
borderData: FlBorderData(show: false),
|
||||
titlesData: FlTitlesData(
|
||||
leftTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 40,
|
||||
interval: yStep,
|
||||
getTitlesWidget: (v, meta) => Padding(
|
||||
padding: const EdgeInsets.only(right: 6),
|
||||
child: Text(
|
||||
v.toStringAsFixed(0),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
color: _color,
|
||||
barWidth: 2.5,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 30,
|
||||
getDotPainter: (spot, _, _, _) {
|
||||
final isSelected = spot.x.toInt() == _selectedIdx;
|
||||
if (isSelected) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 5.5,
|
||||
),
|
||||
),
|
||||
bottomTitles: AxisTitles(
|
||||
sideTitles: SideTitles(
|
||||
showTitles: true,
|
||||
reservedSize: 36,
|
||||
interval: xInterval,
|
||||
getTitlesWidget: (v, meta) {
|
||||
final idx = v.toInt();
|
||||
if (idx < 0 || idx >= _filtered.length) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||
final prevIdx = idx - xInterval.toInt();
|
||||
final showMonth =
|
||||
idx == 0 ||
|
||||
prevIdx < 0 ||
|
||||
(_filtered[prevIdx]['date'] as DateTime)
|
||||
.month !=
|
||||
d.month;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
'${d.day}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
if (showMonth) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${d.month}月',
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textSecondary,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
topTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
rightTitles: const AxisTitles(
|
||||
sideTitles: SideTitles(showTitles: false),
|
||||
),
|
||||
),
|
||||
lineBarsData: [
|
||||
LineChartBarData(
|
||||
spots: spots,
|
||||
isCurved: false,
|
||||
color: _color,
|
||||
strokeWidth: 2.5,
|
||||
strokeColor: Colors.white,
|
||||
);
|
||||
}
|
||||
return FlDotCirclePainter(
|
||||
radius: 3.5,
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
strokeColor: _color,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_color.withValues(alpha: 0.22),
|
||||
_color.withValues(alpha: 0.02),
|
||||
barWidth: 2.5,
|
||||
isStrokeCapRound: true,
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 30,
|
||||
getDotPainter: (spot, _, _, _) {
|
||||
final isSelected =
|
||||
spot.x.toInt() == _selectedIdx;
|
||||
if (isSelected) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 5.5,
|
||||
color: _color,
|
||||
strokeWidth: 2.5,
|
||||
strokeColor: Colors.white,
|
||||
);
|
||||
}
|
||||
return FlDotCirclePainter(
|
||||
radius: 3.5,
|
||||
color: Colors.white,
|
||||
strokeWidth: 2,
|
||||
strokeColor: _color,
|
||||
);
|
||||
},
|
||||
),
|
||||
belowBarData: BarAreaData(
|
||||
show: true,
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
_color.withValues(alpha: 0.22),
|
||||
_color.withValues(alpha: 0.02),
|
||||
],
|
||||
),
|
||||
),
|
||||
shadow: Shadow(
|
||||
color: _color.withValues(alpha: 0.18),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
shadow: Shadow(
|
||||
color: _color.withValues(alpha: 0.18),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
),
|
||||
],
|
||||
lineTouchData: LineTouchData(
|
||||
handleBuiltInTouches: false,
|
||||
touchCallback: (event, response) {
|
||||
if (event is FlTapUpEvent) {
|
||||
final spots = response?.lineBarSpots;
|
||||
if (spots != null && spots.isNotEmpty) {
|
||||
final idx = spots.first.x.toInt();
|
||||
setState(() =>
|
||||
_selectedIdx = (_selectedIdx == idx) ? null : idx);
|
||||
} else {
|
||||
setState(() => _selectedIdx = null);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
if (tooltipPos != null)
|
||||
Positioned(
|
||||
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
|
||||
top: tooltipPos.dy - 56,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _color.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828)
|
||||
.withValues(alpha: 0.14),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
lineTouchData: LineTouchData(
|
||||
handleBuiltInTouches: false,
|
||||
touchCallback: (event, response) {
|
||||
if (event is FlTapUpEvent) {
|
||||
final spots = response?.lineBarSpots;
|
||||
if (spots != null && spots.isNotEmpty) {
|
||||
final idx = spots.first.x.toInt();
|
||||
setState(
|
||||
() => _selectedIdx = (_selectedIdx == idx)
|
||||
? null
|
||||
: idx,
|
||||
);
|
||||
} else {
|
||||
setState(() => _selectedIdx = null);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
duration: Duration.zero,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
tooltipText1!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
if (tooltipPos != null)
|
||||
Positioned(
|
||||
left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0),
|
||||
top: tooltipPos.dy - 56,
|
||||
child: FractionalTranslation(
|
||||
translation: const Offset(-0.5, 0),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(
|
||||
color: _color.withValues(alpha: 0.35),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(
|
||||
0xFF101828,
|
||||
).withValues(alpha: 0.14),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
tooltipText1!,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
color: AppColors.textHint,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tooltipText2!,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: _color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
tooltipText2!,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
color: _color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -731,137 +765,161 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
final date = r['date'] as DateTime;
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
final id = r['id']?.toString() ?? '';
|
||||
final idx = _filtered.indexOf(r);
|
||||
final isSelected = idx == _selectedIdx;
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Dismissible(
|
||||
key: Key(id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.delete('/api/health-records/$id');
|
||||
setState(() {
|
||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
_selectedIdx = null;
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (idx < 0) return;
|
||||
setState(() =>
|
||||
_selectedIdx = isSelected ? null : idx);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
margin: const EdgeInsets.only(bottom: 6),
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? _color.withValues(alpha: 0.15)
|
||||
: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? _color.withValues(alpha: 0.55)
|
||||
: AppColors.border,
|
||||
width: isSelected ? 1.5 : 1,
|
||||
),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_getMetricIcon(_selected),
|
||||
size: 21,
|
||||
color: _color,
|
||||
),
|
||||
Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
..._filtered.reversed.take(30).toList().asMap().entries.map((
|
||||
entry,
|
||||
) {
|
||||
final r = entry.value;
|
||||
final isLast =
|
||||
entry.key == _filtered.reversed.take(30).length - 1;
|
||||
final date = r['date'] as DateTime;
|
||||
final abnormal = r['isAbnormal'] == true;
|
||||
final id = r['id']?.toString() ?? '';
|
||||
final idx = _filtered.indexOf(r);
|
||||
final isSelected = idx == _selectedIdx;
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display =
|
||||
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
return Dismissible(
|
||||
key: Key(id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
color: AppColors.error,
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: const Icon(
|
||||
Icons.delete_outline,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textHint,
|
||||
confirmDismiss: (_) async {
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
await api.delete('/api/health-records/$id');
|
||||
setState(() {
|
||||
_allRecords.removeWhere(
|
||||
(x) => x['id']?.toString() == id,
|
||||
);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
_selectedIdx = null;
|
||||
});
|
||||
return true;
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (idx < 0) return;
|
||||
setState(() => _selectedIdx = isSelected ? null : idx);
|
||||
},
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
color: isSelected
|
||||
? _color.withValues(alpha: 0.10)
|
||||
: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
_getMetricIcon(_selected),
|
||||
size: 21,
|
||||
color: _color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$display $_unit',
|
||||
style: TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: abnormal
|
||||
? AppColors.error
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (abnormal)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
child: const Text(
|
||||
'异常',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'$display $_unit',
|
||||
style: TextStyle(
|
||||
fontSize: 23,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: abnormal
|
||||
? AppColors.error
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (!isLast)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 66),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (abnormal)
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Text(
|
||||
'异常',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -206,6 +206,17 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
name: _deviceNameOf(result),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
|
||||
type: AppToastType.info,
|
||||
);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||
|
||||
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
||||
@@ -223,8 +234,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
} else if (mounted) {
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
if (mounted) popRoute(ref);
|
||||
|
||||
@@ -56,7 +56,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
||||
final item = list[index];
|
||||
return _HistoryTile(
|
||||
item: item,
|
||||
onTap: () => _openConversation(ref, item.id),
|
||||
onTap: () => _openConversation(context, ref, item.id),
|
||||
onDelete: () => _deleteOne(context, ref, item.id),
|
||||
);
|
||||
},
|
||||
@@ -66,8 +66,18 @@ class ConversationHistoryPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openConversation(WidgetRef ref, String id) async {
|
||||
await ref.read(chatProvider.notifier).loadConversation(id);
|
||||
Future<void> _openConversation(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String id,
|
||||
) async {
|
||||
final error = await ref.read(chatProvider.notifier).loadConversation(id);
|
||||
if (error != null) {
|
||||
if (context.mounted) {
|
||||
AppToast.show(context, error, type: AppToastType.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
final imagePath = _pickedImagePath;
|
||||
if (text.isEmpty && imagePath == null) return;
|
||||
_textCtrl.clear();
|
||||
_focusNode.unfocus();
|
||||
_dismissKeyboard();
|
||||
setState(() => _pickedImagePath = null);
|
||||
if (imagePath != null) {
|
||||
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||
@@ -145,9 +145,9 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 44,
|
||||
width: MediaQuery.sizeOf(context).width * 0.8,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (details) {
|
||||
_drawerDragStartX = details.globalPosition.dx;
|
||||
},
|
||||
@@ -255,16 +255,20 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
];
|
||||
|
||||
Widget _buildAgentBar() {
|
||||
final activeAgent = ref.watch(
|
||||
chatProvider.select((state) => state.activeAgent),
|
||||
);
|
||||
return SizedBox(
|
||||
height: 42,
|
||||
height: 46,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
itemCount: _agentDefs.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final agent = _agentDefs[i];
|
||||
final visual = _agentVisual(agent);
|
||||
final selected = activeAgent == agent;
|
||||
return GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(chatProvider.notifier)
|
||||
@@ -272,9 +276,14 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.86),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: Colors.white, width: 1.2),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.auraIndigo.withValues(alpha: 0.42)
|
||||
: const Color(0xFFD5DAE4),
|
||||
width: selected ? 1.4 : 1.1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -477,6 +486,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
|
||||
void _showAttachmentPicker(BuildContext context) {
|
||||
_dismissKeyboard();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
@@ -496,6 +506,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('拍照'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
@@ -507,6 +518,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
@@ -518,6 +530,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('上传 PDF'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
@@ -532,6 +545,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
.read(chatProvider.notifier)
|
||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||
_textCtrl.clear();
|
||||
_dismissKeyboard();
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
@@ -543,17 +557,25 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
_dismissKeyboard();
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
_dismissKeyboard();
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
setState(() => _pickedImagePath = picked.path);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard());
|
||||
}
|
||||
}
|
||||
|
||||
void _dismissKeyboard() {
|
||||
_focusNode.unfocus();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
Future<void> _pickFoodImage(ImageSource source) async {
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
|
||||
@@ -859,13 +859,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: msg.confirmed
|
||||
child: msg.confirmed || msg.isReadOnly
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.successButtonGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
@@ -873,10 +873,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'录入成功',
|
||||
style: TextStyle(
|
||||
msg.isReadOnly ? '历史记录' : '录入成功',
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
@@ -1236,9 +1236,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InteractiveViewer(
|
||||
child: path.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: resolvedPath.startsWith('http')
|
||||
child: resolvedPath.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||
),
|
||||
@@ -1270,20 +1268,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||
// ignore: unused_element
|
||||
static String _cleanAiText(String text) {
|
||||
var t = text;
|
||||
// 移除 $1、$2 等占位符
|
||||
t = t.replaceAll(RegExp(r'\$\d+'), '');
|
||||
// 移除残留 HTML 标签
|
||||
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
// 行首 # 超过 3 个降为 ###
|
||||
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
|
||||
// 压缩多余空行
|
||||
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/// 处理 AI 回复里的 markdown 链接点击:
|
||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||
@@ -2098,77 +2082,3 @@ extension _AgentActionsExt on ActiveAgent {
|
||||
_agentActions[this] ??
|
||||
[const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// 可展开的 AI 建议小组件
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
class _ExpandableAdvice extends StatefulWidget {
|
||||
final String advice;
|
||||
const _ExpandableAdvice({required this.advice});
|
||||
|
||||
@override
|
||||
State<_ExpandableAdvice> createState() => _ExpandableAdviceState();
|
||||
}
|
||||
|
||||
class _ExpandableAdviceState extends State<_ExpandableAdvice> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 19,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'AI 建议',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
_expanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
widget.advice,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF555555),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,90 +102,32 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...List.generate(list.length, (i) {
|
||||
final m = list[i];
|
||||
final times =
|
||||
(m['timeOfDay'] as List?)
|
||||
?.map((t) => t.toString().substring(0, 5))
|
||||
.join(' ') ??
|
||||
'';
|
||||
final isActive = m['isActive'] == true;
|
||||
final id = m['id']?.toString() ?? '';
|
||||
return SwipeDeleteTile(
|
||||
key: Key(id),
|
||||
onDelete: () => _delete(id),
|
||||
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||
margin: const EdgeInsets.symmetric(vertical: 5),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
_MedicationListGroup(
|
||||
children: List.generate(list.length, (i) {
|
||||
final m = list[i];
|
||||
final times =
|
||||
(m['timeOfDay'] as List?)
|
||||
?.map((t) => t.toString().substring(0, 5))
|
||||
.join(' ') ??
|
||||
'';
|
||||
final isActive = m['isActive'] == true;
|
||||
final id = m['id']?.toString() ?? '';
|
||||
return SwipeDeleteTile(
|
||||
key: Key(id),
|
||||
onDelete: () => _delete(id),
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||
margin: EdgeInsets.zero,
|
||||
child: _MedicationRow(
|
||||
name: m['name']?.toString() ?? '',
|
||||
subtitle: '${m['dosage'] ?? ''} $times',
|
||||
isActive: isActive,
|
||||
showDivider: i < list.length - 1,
|
||||
visual: _medVisual,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive
|
||||
? _medVisual.gradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(
|
||||
_medVisual.icon,
|
||||
size: 25,
|
||||
color: isActive ? Colors.white : AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.sMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
m['name']?.toString() ?? '',
|
||||
style: AppTextStyles.listTitle.copyWith(
|
||||
color: isActive
|
||||
? AppTheme.text
|
||||
: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
if (!isActive) ...[
|
||||
const SizedBox(width: 6),
|
||||
AppStatusBadge(
|
||||
label: '已停',
|
||||
color: _medVisual.color,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${m['dosage'] ?? ''} $times',
|
||||
style: AppTextStyles.listSubtitle.copyWith(
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@@ -193,3 +135,125 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MedicationListGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _MedicationListGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MedicationRow extends StatelessWidget {
|
||||
final String name;
|
||||
final String subtitle;
|
||||
final bool isActive;
|
||||
final bool showDivider;
|
||||
final AppModuleVisual visual;
|
||||
|
||||
const _MedicationRow({
|
||||
required this.name,
|
||||
required this.subtitle,
|
||||
required this.isActive,
|
||||
required this.showDivider,
|
||||
required this.visual,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: AppTheme.sLg,
|
||||
vertical: 13,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive
|
||||
? visual.gradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(
|
||||
visual.icon,
|
||||
size: 25,
|
||||
color: isActive ? Colors.white : AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: AppTheme.sMd),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.listTitle.copyWith(
|
||||
color: isActive
|
||||
? AppTheme.text
|
||||
: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!isActive) ...[
|
||||
const SizedBox(width: 6),
|
||||
AppStatusBadge(label: '已停', color: visual.color),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.listSubtitle.copyWith(
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 74),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -240,7 +241,12 @@ class _NotificationCenterPageState
|
||||
if (today.isNotEmpty) ...[
|
||||
const _SectionTitle('今天'),
|
||||
const SizedBox(height: 10),
|
||||
...today.map(_buildDismissible),
|
||||
_NotificationGroup(
|
||||
children: [
|
||||
for (var i = 0; i < today.length; i++)
|
||||
_buildDismissible(today[i], showDivider: i < today.length - 1),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (earlier.isNotEmpty) ...[
|
||||
if (today.isNotEmpty) const SizedBox(height: 18),
|
||||
@@ -251,49 +257,81 @@ class _NotificationCenterPageState
|
||||
onTap: () => setState(() => _showEarlier = !_showEarlier),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
if (_showEarlier) ...earlier.map(_buildDismissible),
|
||||
if (_showEarlier)
|
||||
_NotificationGroup(
|
||||
children: [
|
||||
for (var i = 0; i < earlier.length; i++)
|
||||
_buildDismissible(
|
||||
earlier[i],
|
||||
showDivider: i < earlier.length - 1,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildDismissible(InAppNotification item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 26),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.trash2, color: Colors.white, size: 22),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
Widget _buildDismissible(
|
||||
InAppNotification item, {
|
||||
required bool showDivider,
|
||||
}) => Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 26),
|
||||
color: AppColors.error,
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(LucideIcons.trash2, color: Colors.white, size: 22),
|
||||
SizedBox(width: 8),
|
||||
Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onDismissed: (_) => _delete(item),
|
||||
child: SizedBox(
|
||||
height: 98,
|
||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onDismissed: (_) => _delete(item),
|
||||
child: _NotificationRow(
|
||||
item: item,
|
||||
showDivider: showDivider,
|
||||
onTap: () => _open(item),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _NotificationGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _NotificationGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.035),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _UnreadHint extends StatelessWidget {
|
||||
final int unreadCount;
|
||||
|
||||
@@ -305,7 +343,7 @@ class _UnreadHint extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||
),
|
||||
child: Row(
|
||||
@@ -359,23 +397,8 @@ class _SectionShell extends StatelessWidget {
|
||||
const _SectionShell({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 6, 0, 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.035),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
Widget build(BuildContext context) =>
|
||||
Padding(padding: const EdgeInsets.fromLTRB(2, 12, 2, 8), child: child);
|
||||
}
|
||||
|
||||
class _CollapsibleSectionTitle extends StatelessWidget {
|
||||
@@ -433,38 +456,31 @@ class _CollapsibleSectionTitle extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
class _NotificationCard extends StatelessWidget {
|
||||
class _NotificationRow extends StatelessWidget {
|
||||
final InAppNotification item;
|
||||
final bool showDivider;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _NotificationCard({required this.item, required this.onTap});
|
||||
const _NotificationRow({
|
||||
required this.item,
|
||||
required this.showDivider,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final visual = _NotificationVisual.of(item);
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
height: 98,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: item.isRead
|
||||
? AppColors.borderLight
|
||||
: visual.color.withValues(alpha: 0.32),
|
||||
width: 1.1,
|
||||
),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Stack(
|
||||
constraints: const BoxConstraints(minHeight: 94),
|
||||
color: Colors.white,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
@@ -482,7 +498,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
visual.lightColor,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
color: visual.color.withValues(alpha: 0.22),
|
||||
),
|
||||
@@ -527,7 +543,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
visual.label,
|
||||
@@ -589,6 +605,15 @@ class _NotificationCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 75),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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';
|
||||
@@ -721,11 +722,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
} catch (e) {
|
||||
debugPrint('[ExercisePlan] 打卡失败: $e');
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'只能打卡今天的运动任务',
|
||||
type: AppToastType.error,
|
||||
);
|
||||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||
@@ -1304,11 +1301,7 @@ class _ExercisePlanDetailPageState
|
||||
} catch (e) {
|
||||
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'只能打卡今天的运动任务',
|
||||
type: AppToastType.error,
|
||||
);
|
||||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||
@@ -2462,7 +2455,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
@@ -2506,12 +2499,10 @@ class _Section extends StatelessWidget {
|
||||
};
|
||||
@override
|
||||
Widget build(BuildContext c) => Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -2519,26 +2510,26 @@ class _Section extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: _color),
|
||||
child: Icon(icon, size: 18, color: _color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
...fields,
|
||||
],
|
||||
),
|
||||
@@ -2572,15 +2563,15 @@ class _F extends StatelessWidget {
|
||||
vertical: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||
),
|
||||
),
|
||||
@@ -2610,7 +2601,7 @@ class _GenderF extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Row(
|
||||
@@ -2641,7 +2632,7 @@ class _GenderChip extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF2563EB) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -2685,15 +2676,15 @@ class _F2 extends StatelessWidget {
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||
),
|
||||
),
|
||||
@@ -2723,7 +2714,7 @@ class _DateF extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -2774,7 +2765,7 @@ class _DateF2 extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -2824,7 +2815,7 @@ class _AddBtn extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: const Row(
|
||||
@@ -3366,13 +3357,119 @@ class _Legend extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
const Map<String, String> _extraStaticTextTitles = {
|
||||
'personalInfoList': '个人信息收集清单',
|
||||
'thirdPartySdkList': '第三方 SDK 共享清单',
|
||||
};
|
||||
|
||||
const Map<String, String> _extraStaticTextContents = {
|
||||
'personalInfoList': '''## 个人信息收集清单
|
||||
|
||||
更新日期:2026年7月9日
|
||||
|
||||
本清单用于说明小脉健康 App 在当前功能范围内可能收集的个人信息类型、使用目的和使用场景。实际启用情况以正式部署配置和您实际使用的功能为准。
|
||||
|
||||
### 一、账号与登录信息
|
||||
|
||||
- 信息内容:手机号、登录状态、账号标识、验证码校验结果。
|
||||
- 使用目的:完成注册、登录、身份识别、账号安全校验和账号注销。
|
||||
- 使用场景:登录、注册、刷新登录状态、删除账号。
|
||||
|
||||
### 二、个人资料和健康档案
|
||||
|
||||
- 信息内容:姓名或昵称、性别、出生日期、头像、过敏史、慢病史、家族史、手术信息、饮食禁忌等。
|
||||
- 使用目的:建立健康档案,辅助 AI 健康咨询、医生咨询、报告解读和健康管理。
|
||||
- 使用场景:完善资料、健康档案、AI 对话、医生端查看。
|
||||
|
||||
### 三、健康数据
|
||||
|
||||
- 信息内容:血压、心率、血糖、血氧、体重、记录时间、数据来源、异常状态。
|
||||
- 使用目的:记录和展示健康趋势,生成健康提醒,辅助 AI 分析和医生咨询。
|
||||
- 使用场景:手动录入、蓝牙血压计同步、健康概览、通知提醒、AI 对话确认录入。
|
||||
|
||||
### 四、用药、运动、饮食和日历数据
|
||||
|
||||
- 信息内容:药品名称、剂量、频次、服药时间、打卡记录、运动计划、饮食记录、热量估算、日程提醒。
|
||||
- 使用目的:提供用药管理、运动计划、饮食识别、健康提醒和日历管理。
|
||||
- 使用场景:用药管理、服药打卡、运动计划、饮食拍照识别、通知中心。
|
||||
|
||||
### 五、报告、图片和文件信息
|
||||
|
||||
- 信息内容:您主动上传的检查报告图片、聊天图片、PDF 文件、AI 识别结果、报告摘要和审核状态。
|
||||
- 使用目的:完成报告管理、报告解读、图片识别、AI 对话附件分析。
|
||||
- 使用场景:上传报告、拍照识别、AI 聊天发送照片或 PDF。
|
||||
|
||||
### 六、蓝牙设备信息
|
||||
|
||||
- 信息内容:设备名称、设备标识、设备类型、服务 UUID、最近同步时间、短期去重记录。
|
||||
- 使用目的:发现、绑定和同步支持的健康设备。
|
||||
- 使用场景:蓝牙设备扫描、血压计绑定、血压数据同步。
|
||||
|
||||
### 七、设备、日志和网络信息
|
||||
|
||||
- 信息内容:设备型号、系统版本、App 版本、接口请求状态、错误信息、网络状态。
|
||||
- 使用目的:功能适配、问题排查、服务安全、性能优化和合规审计。
|
||||
- 使用场景:App 使用过程中的接口访问、异常处理、故障排查。
|
||||
''',
|
||||
'thirdPartySdkList': '''## 第三方 SDK 共享清单
|
||||
|
||||
更新日期:2026年7月9日
|
||||
|
||||
本清单用于说明小脉健康 App 当前可能接入或依赖的第三方 SDK、外部接口和服务能力。正式上线前会根据实际启用的服务商、域名和资质信息继续更新。
|
||||
|
||||
### 一、AI 大模型和视觉识别服务
|
||||
|
||||
- 使用目的:AI 健康咨询、报告解读、图片识别、文本生成和内容理解。
|
||||
- 可能共享的信息:您主动输入的对话内容、上传的图片或 PDF、健康档案摘要、需要 AI 分析的健康数据。
|
||||
- 使用场景:AI 对话、拍照识别、上传报告、报告解读。
|
||||
- 说明:实际服务商和接口地址以正式环境配置为准。
|
||||
|
||||
### 二、短信验证服务
|
||||
|
||||
- 使用目的:发送登录或注册验证码。
|
||||
- 可能共享的信息:手机号、验证码发送状态、必要的风控信息。
|
||||
- 使用场景:手机号登录、注册、身份验证。
|
||||
- 说明:当前正式短信服务仍待接入,正式启用后补充服务商信息。
|
||||
|
||||
### 三、文件存储或服务器存储服务
|
||||
|
||||
- 使用目的:保存您主动上传的报告、图片和 PDF 文件。
|
||||
- 可能共享的信息:文件内容、文件名称、上传时间、账号标识。
|
||||
- 使用场景:报告管理、AI 聊天附件、图片识别。
|
||||
- 说明:当前仍处于本地和开发环境阶段,正式服务器和域名确定后补充公网信息。
|
||||
|
||||
### 四、实时通信服务
|
||||
|
||||
- 使用目的:支持医生咨询消息、AI 流式回复和实时状态更新。
|
||||
- 可能共享的信息:消息内容、会话标识、发送时间、账号标识。
|
||||
- 使用场景:AI 对话、医生咨询、通知状态更新。
|
||||
|
||||
### 五、系统能力和开源组件
|
||||
|
||||
- 使用目的:蓝牙扫描、相机/相册选择、文件选择、图表展示、Markdown 渲染、网络请求等 App 基础能力。
|
||||
- 可能涉及的信息:蓝牙设备信息、您主动选择的图片或文件、网络请求状态。
|
||||
- 使用场景:蓝牙设备、上传照片、上传 PDF、健康趋势、合规文本展示。
|
||||
|
||||
我们不会向第三方出售您的个人信息。涉及个人信息处理的第三方服务,会在实现相关功能所必需的范围内使用,并在正式上线前结合实际服务商完善披露信息。
|
||||
''',
|
||||
};
|
||||
|
||||
String staticTextTitle(String type) => _extraStaticTextTitles[type] ?? '';
|
||||
|
||||
String staticTextContent(String type) => _extraStaticTextContents[type] ?? '';
|
||||
|
||||
/// 静态文本页
|
||||
class StaticTextPage extends ConsumerWidget {
|
||||
final String type;
|
||||
const StaticTextPage({super.key, required this.type});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'};
|
||||
final titles = {
|
||||
'privacy': '隐私协议',
|
||||
'terms': '服务协议',
|
||||
'about': '关于小脉健康',
|
||||
..._extraStaticTextTitles,
|
||||
};
|
||||
final contents = {
|
||||
'privacy': '''## 小脉健康隐私政策
|
||||
|
||||
@@ -3676,6 +3773,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
### 版权声明
|
||||
© 2025-2026 小脉健康团队。保留所有权利。
|
||||
本软件受中华人民共和国著作权法保护。''',
|
||||
..._extraStaticTextContents,
|
||||
};
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.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/api_client.dart' show baseUrl;
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/common_widgets.dart';
|
||||
import '../../widgets/enterprise_widgets.dart';
|
||||
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
||||
@@ -447,8 +449,26 @@ class ReportListPage extends ConsumerWidget {
|
||||
if (state.reports.isEmpty)
|
||||
_buildEmptyState(context)
|
||||
else
|
||||
...state.reports.map(
|
||||
(report) => _buildReportCard(context, ref, report),
|
||||
_ReportListGroup(
|
||||
children: [
|
||||
for (var i = 0; i < state.reports.length; i++)
|
||||
SwipeDeleteTile(
|
||||
key: Key(state.reports[i].id),
|
||||
onDelete: () => ref
|
||||
.read(reportProvider.notifier)
|
||||
.deleteReport(state.reports[i].id),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'aiAnalysis',
|
||||
params: {'id': state.reports[i].id},
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
child: _buildReportRow(
|
||||
state.reports[i],
|
||||
showDivider: i < state.reports.length - 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -461,7 +481,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
|
||||
),
|
||||
child: Row(
|
||||
@@ -576,7 +596,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
||||
),
|
||||
@@ -599,126 +619,93 @@ class ReportListPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildReportCard(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ReportItem report,
|
||||
) {
|
||||
Widget _buildReportRow(ReportItem report, {required bool showDivider}) {
|
||||
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
||||
? '检查报告'
|
||||
: report.title;
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
child: InkWell(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.10),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
),
|
||||
child: Icon(
|
||||
_reportVisual.icon,
|
||||
size: 24,
|
||||
color: _reportBlue,
|
||||
),
|
||||
),
|
||||
child: Icon(_reportVisual.icon, size: 26, color: _reportBlue),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_formatDate(report.uploadedAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
if (report.aiStatus == 'Failed') ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_failureSummary(report),
|
||||
maxLines: 2,
|
||||
displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
style: AppTextStyles.listTitle.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
_formatDate(report.uploadedAt),
|
||||
style: AppTextStyles.listSubtitle,
|
||||
),
|
||||
if (report.aiStatus == 'Failed') ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
_failureSummary(report),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
height: 1.35,
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildStatusBadge(report),
|
||||
const SizedBox(width: 4),
|
||||
IconButton(
|
||||
onPressed: () => _confirmDelete(context, ref, report.id),
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(
|
||||
Icons.delete_outline,
|
||||
size: 20,
|
||||
const SizedBox(width: 8),
|
||||
_buildStatusBadge(report),
|
||||
const SizedBox(width: 8),
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 71),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _confirmDelete(BuildContext context, WidgetRef ref, String id) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除报告'),
|
||||
content: const Text('确定要删除这份报告吗?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Navigator.pop(ctx);
|
||||
ref.read(reportProvider.notifier).deleteReport(id);
|
||||
},
|
||||
child: const Text('删除', style: TextStyle(color: AppColors.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStatusBadge(ReportItem report) {
|
||||
final (label, bg, fg) = switch (report.status) {
|
||||
_ when report.reviewStatus == 'Reviewed' => (
|
||||
@@ -748,7 +735,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: fg.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Text(
|
||||
@@ -776,6 +763,24 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportListGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _ReportListGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _catTitle(String c) => switch (c) {
|
||||
'BloodTest' => '抽血化验单',
|
||||
'Biochemistry' => '生化检验报告',
|
||||
@@ -786,20 +791,20 @@ String _catTitle(String c) => switch (c) {
|
||||
_ => '检查报告',
|
||||
};
|
||||
|
||||
class ReportOriginalPage extends StatelessWidget {
|
||||
class ReportOriginalPage extends ConsumerWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final imageUrl = _absoluteUrl(url);
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: Text(title),
|
||||
),
|
||||
|
||||
@@ -144,191 +144,211 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── 推送总开关 ──
|
||||
_SectionTitle(title: '推送通知'),
|
||||
_SwitchTile(
|
||||
title: '允许推送通知',
|
||||
subtitle: '关闭后将不再收到任何系统推送',
|
||||
value: prefs['pushEnabled'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('pushEnabled'),
|
||||
_SettingsListSection(
|
||||
title: '推送通知',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
title: '允许推送通知',
|
||||
subtitle: '关闭后将不再收到任何系统推送',
|
||||
value: prefs['pushEnabled'] ?? true,
|
||||
showDivider: false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('pushEnabled'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 各类通知开关 ──
|
||||
_SectionTitle(title: '通知类型'),
|
||||
_SwitchTile(
|
||||
icon: Icons.medication_rounded,
|
||||
iconBg: AppColors.errorLight,
|
||||
iconColor: AppColors.error,
|
||||
title: '用药提醒',
|
||||
subtitle: '服药时间到达时提醒您',
|
||||
value: prefs['medication'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('medication'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconBg: AppColors.errorLight,
|
||||
iconColor: AppTheme.error,
|
||||
title: '健康异常提醒',
|
||||
subtitle: '检测到数据异常时及时通知',
|
||||
value: prefs['healthAlert'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthAlert'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.event_available_rounded,
|
||||
iconBg: AppColors.successLight,
|
||||
iconColor: AppColors.success,
|
||||
title: '复查日期提醒',
|
||||
subtitle: '复查日前一天提醒您预约',
|
||||
value: prefs['followUp'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('followUp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.forum_outlined,
|
||||
iconBg: AppColors.iconBg,
|
||||
iconColor: AppTheme.primary,
|
||||
title: 'AI 回复通知',
|
||||
subtitle: 'AI 助手回复时发送通知',
|
||||
value: prefs['aiReply'] ?? false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('aiReply'),
|
||||
_SettingsListSection(
|
||||
title: '通知类型',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
icon: Icons.medication_rounded,
|
||||
iconBg: AppColors.errorLight,
|
||||
iconColor: AppColors.error,
|
||||
title: '用药提醒',
|
||||
subtitle: '服药时间到达时提醒您',
|
||||
value: prefs['medication'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('medication'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.warning_amber_rounded,
|
||||
iconBg: AppColors.errorLight,
|
||||
iconColor: AppTheme.error,
|
||||
title: '健康异常提醒',
|
||||
subtitle: '检测到数据异常时及时通知',
|
||||
value: prefs['healthAlert'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthAlert'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.event_available_rounded,
|
||||
iconBg: AppColors.successLight,
|
||||
iconColor: AppColors.success,
|
||||
title: '复查日期提醒',
|
||||
subtitle: '复查日前一天提醒您预约',
|
||||
value: prefs['followUp'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('followUp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
icon: Icons.forum_outlined,
|
||||
iconBg: AppColors.iconBg,
|
||||
iconColor: AppTheme.primary,
|
||||
title: 'AI 回复通知',
|
||||
subtitle: 'AI 助手回复时发送通知',
|
||||
value: prefs['aiReply'] ?? false,
|
||||
showDivider: false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('aiReply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 健康录入提醒 ──
|
||||
_SectionTitle(title: '健康录入提醒'),
|
||||
_SwitchTile(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
iconBg: const Color(0xFFFEF3C7),
|
||||
iconColor: const Color(0xFFD97706),
|
||||
title: '每日录入提醒',
|
||||
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||
value: prefs['healthRecord'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord'),
|
||||
_SettingsListSection(
|
||||
title: '健康录入提醒',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
iconBg: const Color(0xFFFEF3C7),
|
||||
iconColor: const Color(0xFFD97706),
|
||||
title: '每日录入提醒',
|
||||
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||
value: prefs['healthRecord'] ?? true,
|
||||
showDivider: prefs['healthRecord'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord'),
|
||||
),
|
||||
if (prefs['healthRecord'] ?? true) ...[
|
||||
_SwitchTile(
|
||||
title: ' · 血压',
|
||||
value: prefs['healthRecord.bp'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.bp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 心率',
|
||||
value: prefs['healthRecord.hr'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.hr'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血糖',
|
||||
value: prefs['healthRecord.glucose'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.glucose'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血氧',
|
||||
value: prefs['healthRecord.spo2'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.spo2'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 体重',
|
||||
value: prefs['healthRecord.weight'] ?? true,
|
||||
showDivider: false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.weight'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (prefs['healthRecord'] ?? true) ...[
|
||||
_SwitchTile(
|
||||
title: ' · 血压',
|
||||
value: prefs['healthRecord.bp'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.bp'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 心率',
|
||||
value: prefs['healthRecord.hr'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.hr'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血糖',
|
||||
value: prefs['healthRecord.glucose'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.glucose'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 血氧',
|
||||
value: prefs['healthRecord.spo2'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.spo2'),
|
||||
),
|
||||
_SwitchTile(
|
||||
title: ' · 体重',
|
||||
value: prefs['healthRecord.weight'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.weight'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 免打扰时段 ──
|
||||
_SectionTitle(title: '免打扰时段'),
|
||||
_SwitchTile(
|
||||
title: '开启免打扰模式',
|
||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||
value: dndOn,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('dndEnabled'),
|
||||
),
|
||||
if (dndOn) ...[
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
_SettingsListSection(
|
||||
title: '免打扰时段',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
title: '开启免打扰模式',
|
||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||
value: dndOn,
|
||||
showDivider: dndOn,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('dndEnabled'),
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '开始',
|
||||
time: '22:00',
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndStart(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
if (dndOn)
|
||||
Container(
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'~',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppTheme.textHint,
|
||||
decoration: BoxDecoration(color: AppTheme.surface),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '开始',
|
||||
time: '22:00',
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(
|
||||
hour: 22,
|
||||
minute: 0,
|
||||
),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndStart(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: Text(
|
||||
'~',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppTheme.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '结束',
|
||||
time: '08:00',
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndEnd(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Expanded(
|
||||
child: _TimeButton(
|
||||
label: '结束',
|
||||
time: '08:00',
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.setDndEnd(picked);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -358,6 +378,30 @@ class _SectionTitle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsListSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsListSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(title: title),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
child: ColoredBox(
|
||||
color: AppTheme.surface,
|
||||
child: Column(children: children),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchTile extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final Color? iconBg;
|
||||
@@ -365,6 +409,7 @@ class _SwitchTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool value;
|
||||
final bool showDivider;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _SwitchTile({
|
||||
@@ -374,33 +419,38 @@ class _SwitchTile extends StatelessWidget {
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
this.showDivider = true,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
border: showDivider
|
||||
? Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.borderLight.withValues(alpha: 0.75),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg ?? AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 23,
|
||||
size: 20,
|
||||
color: iconColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
@@ -413,15 +463,17 @@ class _SwitchTile extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
const SizedBox(height: 2),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSub),
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textSub),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ 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_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -38,37 +39,58 @@ class SettingsPage extends ConsumerWidget {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.info,
|
||||
title: '关于小脉健康',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: '隐私协议',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'privacy'}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: '服务协议',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
|
||||
_SettingsGroup(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
title: '蓝牙设备',
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.info,
|
||||
title: '关于小脉健康',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: '隐私协议',
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'staticText',
|
||||
params: {'type': 'privacy'},
|
||||
),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.fact_check_outlined,
|
||||
title: '个人信息收集清单',
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'staticText',
|
||||
params: {'type': 'personalInfoList'},
|
||||
),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.hub_outlined,
|
||||
title: '第三方 SDK 共享清单',
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'staticText',
|
||||
params: {'type': 'thirdPartySdkList'},
|
||||
),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: '服务协议',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
@@ -172,6 +194,39 @@ class SettingsPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsGroup extends StatelessWidget {
|
||||
final List<_SettingsTile> children;
|
||||
|
||||
const _SettingsGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
for (var i = 0; i < children.length; i++) ...[
|
||||
children[i],
|
||||
if (i < children.length - 1)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 52),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -186,17 +241,10 @@ class _SettingsTile extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: Colors.black, size: 22),
|
||||
|
||||
Reference in New Issue
Block a user