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:
MingNian
2026-07-09 15:04:02 +08:00
parent 1c020b8ae5
commit d82e006cf4
37 changed files with 1842 additions and 2096 deletions

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: 'http://10.4.212.224:5000',
defaultValue: 'http://10.4.221.78:5000',
);
class ApiException implements Exception {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,6 +27,7 @@ class ChatMessage {
this.confirmed = false,
});
bool get isUser => role == 'user';
bool get isReadOnly => metadata?['readOnly'] == true;
}
enum ActiveAgent {
@@ -67,16 +68,6 @@ class ChatState {
);
}
class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
@override
ActiveAgent? build() => null;
void select(ActiveAgent? a) => state = a;
}
final selectedAgentProvider =
NotifierProvider<SelectedAgentNotifier, ActiveAgent?>(
SelectedAgentNotifier.new,
);
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
ChatNotifier.new,
);
@@ -85,8 +76,8 @@ class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent;
void markNeedsRebuild() => state = state.copyWith();
Timer? _agentWelcomeTimer;
String? _pendingAgentTriggerMessageId;
/// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。
@@ -94,7 +85,6 @@ class ChatNotifier extends Notifier<ChatState> {
await _cancelActiveStream();
_lastTriggeredAgent = null;
state = const ChatState();
ref.read(selectedAgentProvider.notifier).select(null);
}
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
@@ -102,6 +92,7 @@ class ChatNotifier extends Notifier<ChatState> {
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
@@ -142,6 +133,7 @@ class ChatNotifier extends Notifier<ChatState> {
ChatState build() {
ref.onDispose(() {
_subscription?.cancel();
_agentWelcomeTimer?.cancel();
_subscription = null;
if (_streamDone != null && !_streamDone!.isCompleted) {
_streamDone!.complete();
@@ -170,14 +162,6 @@ class ChatNotifier extends Notifier<ChatState> {
);
}
void setAgent(ActiveAgent a) {
// 流式回复中忽略胶囊切换,防止状态混乱
if (state.isStreaming) return;
_cancelActiveStream();
state = state.copyWith(activeAgent: a);
ref.read(selectedAgentProvider.notifier).select(a);
}
/// 根据 AI 调用的工具自动切换智能体胶囊
void _switchAgentByTool(String tool) {
ActiveAgent? agent;
@@ -203,13 +187,13 @@ class ChatNotifier extends Notifier<ChatState> {
break;
}
if (agent != null) {
ref.read(selectedAgentProvider.notifier).select(agent);
state = state.copyWith(activeAgent: agent);
}
}
Future<void> loadConversation(String convId) async {
Future<String?> loadConversation(String convId) async {
await _cancelActiveStream();
_cancelPendingAgentWelcome();
try {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations/$convId');
@@ -219,7 +203,8 @@ class ChatNotifier extends Notifier<ChatState> {
final role = map['role']?.toString().toLowerCase() == 'user'
? 'user'
: 'assistant';
final metadata = _parseMetadata(map['metadataJson']);
final metadata = _parseMetadata(map['metadataJson']) ?? {};
metadata['readOnly'] = true;
return ChatMessage(
id: map['id']?.toString() ?? '',
role: role,
@@ -229,6 +214,7 @@ class ChatNotifier extends Notifier<ChatState> {
DateTime.now(),
type: _messageTypeFromMetadata(metadata),
metadata: metadata,
confirmed: metadata['confirmationIds'] is! List,
);
}).toList();
@@ -237,11 +223,14 @@ class ChatNotifier extends Notifier<ChatState> {
conversationId: convId,
activeAgent: ActiveAgent.default_,
);
ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_);
} catch (_) {}
return null;
} catch (_) {
return '会话加载失败,请稍后重试';
}
}
void insertAgentWelcome(ActiveAgent agent) {
_pendingAgentTriggerMessageId = null;
state = state.copyWith(
messages: [
...state.messages,
@@ -260,7 +249,12 @@ class ChatNotifier extends Notifier<ChatState> {
/// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI
/// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) {
if (_lastTriggeredAgent == agent) return;
if (_pendingAgentTriggerMessageId != null) {
return;
}
if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) {
return;
}
_lastTriggeredAgent = agent;
final userMsg = ChatMessage(
@@ -269,18 +263,35 @@ class ChatNotifier extends Notifier<ChatState> {
content: label,
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, userMsg]);
final messages = state.messages.toList();
messages.add(userMsg);
_pendingAgentTriggerMessageId = userMsg.id;
state = state.copyWith(messages: messages, activeAgent: agent);
final expectedConversationId = state.conversationId;
Future.delayed(const Duration(milliseconds: 400), () {
_agentWelcomeTimer = Timer(Duration.zero, () {
if (state.conversationId != expectedConversationId ||
state.activeAgent != agent ||
_lastTriggeredAgent != agent) {
return;
}
_agentWelcomeTimer = null;
insertAgentWelcome(agent);
});
}
void _cancelPendingAgentWelcome() {
_agentWelcomeTimer?.cancel();
_agentWelcomeTimer = null;
_pendingAgentTriggerMessageId = null;
}
Future<void> sendImage(String imagePath, String text) async {
if (state.isStreaming) return;
final file = File(imagePath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
// 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage(
@@ -290,7 +301,10 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(),
metadata: {'localImagePath': imagePath},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
);
// 异步上传图片
String? uploadedUrl;
@@ -328,6 +342,7 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
state = state.copyWith(isStreaming: false);
return;
}
@@ -342,6 +357,7 @@ class ChatNotifier extends Notifier<ChatState> {
final file = File(pdfPath);
if (!await file.exists()) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
@@ -350,7 +366,10 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(),
metadata: {'pdfFileName': fileName},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
state = state.copyWith(
messages: [...state.messages, userMsg],
isStreaming: true,
);
String? uploadedUrl;
try {
@@ -382,6 +401,7 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(),
);
state = state.copyWith(messages: [...state.messages, errorMsg]);
state = state.copyWith(isStreaming: false);
return;
}
@@ -391,6 +411,7 @@ class ChatNotifier extends Notifier<ChatState> {
Future<void> sendMessage(String text) async {
if (text.trim().isEmpty || state.isStreaming) return;
_lastTriggeredAgent = null;
_cancelPendingAgentWelcome();
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',

View File

@@ -24,7 +24,8 @@ class ConversationListItem {
title: json['title']?.toString(),
summary: json['summary']?.toString(),
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
updatedAt:
DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
DateTime.now(),
);
}
@@ -33,15 +34,20 @@ class ConversationListItem {
class ConversationHistoryNotifier
extends AsyncNotifier<List<ConversationListItem>> {
@override
Future<List<ConversationListItem>> build() => _fetch();
Future<List<ConversationListItem>> build() {
ref.watch(chatProvider.select((state) => state.conversationId));
return _fetch();
}
Future<List<ConversationListItem>> _fetch() async {
final api = ref.read(apiClientProvider);
final res = await api.get('/api/ai/conversations');
final raw = (res.data['data'] as List?) ?? const [];
final currentConversationId = ref.read(chatProvider).conversationId;
return raw
.whereType<Map>()
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
.where((item) => item.id != currentConversationId)
.toList();
}
@@ -79,7 +85,8 @@ class ConversationHistoryNotifier
}
}
final conversationHistoryProvider = AsyncNotifierProvider<
ConversationHistoryNotifier, List<ConversationListItem>>(
ConversationHistoryNotifier.new,
);
final conversationHistoryProvider =
AsyncNotifierProvider<
ConversationHistoryNotifier,
List<ConversationListItem>
>(ConversationHistoryNotifier.new);

View File

@@ -60,11 +60,6 @@ final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
return service.getLatest();
});
/// AI 录入数据后调用,刷新侧边栏
void refreshHealthData(WidgetRef ref) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
}
/// 用药列表 Provider
final medicationListProvider =
@@ -87,14 +82,6 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
return service.getDoctors().timeout(const Duration(seconds: 8));
});
/// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((
ref,
) async {
final service = ref.watch(consultationServiceProvider);
return service.getQuota();
});
/// 当前运动计划 Provider
final currentExercisePlanProvider =
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {

View File

@@ -21,10 +21,6 @@ final healthBleServiceProvider = Provider<HealthBleService>((ref) {
return service;
});
// Keep the old provider name as a compatibility alias while the feature is
// being migrated away from the Omron-specific naming.
final omronBleServiceProvider = healthBleServiceProvider;
class DeviceBindState {
final List<BoundBleDevice> devices;
final bool isConnected;

View File

@@ -35,6 +35,10 @@ class HealthBleService {
return BleDeviceIdentifier.typeFromScan(result);
}
static bool isSyncImplemented(BleDeviceType type) {
return type == BleDeviceType.bloodPressure;
}
Future<BoundBleDevice> connectAndIdentify({
required BluetoothDevice device,
required String name,
@@ -65,6 +69,9 @@ class HealthBleService {
if (connectedType != bound.type) {
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
}
if (!isSyncImplemented(bound.type)) {
throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通');
}
return switch (bound.type) {
BleDeviceType.bloodPressure => BloodPressureSyncResult(

View File

@@ -1,40 +1,15 @@
import '../core/api_client.dart';
/// 健康数据服务
class HealthService {
final ApiClient _api;
HealthService(this._api);
/// 获取各指标最新值
Future<Map<String, dynamic>> getLatest() async {
final res = await _api.get('/api/health-records/latest');
return res.data['data'] ?? {};
}
/// 获取趋势数据
Future<List<Map<String, dynamic>>> getTrend(String type, {int period = 7}) async {
final res = await _api.get('/api/health-records/trend', queryParameters: {'type': type, 'period': period});
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// 获取记录列表
Future<List<Map<String, dynamic>>> getRecords({String? type, int? days}) async {
final params = <String, dynamic>{};
if (type != null) params['type'] = type;
if (days != null) params['days'] = days;
final res = await _api.get('/api/health-records', queryParameters: params);
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
/// 删除记录
Future<void> deleteRecord(String id) async {
await _api.delete('/api/health-records/$id');
}
}
/// 用户服务
class UserService {
final ApiClient _api;
UserService(this._api);
@@ -44,8 +19,15 @@ class UserService {
return res.data['data'];
}
Future<void> updateProfile({String? name, String? gender, String? birthDate}) async {
await _api.put('/api/user/profile', data: {'name': name, 'gender': gender, 'birthDate': birthDate});
Future<void> updateProfile({
String? name,
String? gender,
String? birthDate,
}) async {
await _api.put(
'/api/user/profile',
data: {'name': name, 'gender': gender, 'birthDate': birthDate},
);
}
Future<Map<String, dynamic>?> getHealthArchive() async {
@@ -62,7 +44,6 @@ class UserService {
}
}
/// 用药服务
class MedicationService {
final ApiClient _api;
MedicationService(this._api);
@@ -81,14 +62,13 @@ class MedicationService {
await _api.put('/api/medications/$id', data: data);
}
Future<void> delete(String id) async {
await _api.delete('/api/medications/$id');
}
Future<List<Map<String, dynamic>>> getMedications(String filter) async {
final params = <String, dynamic>{};
if (filter.isNotEmpty) params['filter'] = filter;
final res = await _api.get('/api/medications', queryParameters: params.isNotEmpty ? params : null);
final res = await _api.get(
'/api/medications',
queryParameters: params.isNotEmpty ? params : null,
);
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
@@ -108,12 +88,14 @@ class MedicationService {
}
}
/// 饮食服务
class DietService {
final ApiClient _api;
DietService(this._api);
Future<List<Map<String, dynamic>>> getRecords({String? date, String? mealType}) async {
Future<List<Map<String, dynamic>>> getRecords({
String? date,
String? mealType,
}) async {
final params = <String, dynamic>{};
if (date != null) params['date'] = date;
if (mealType != null) params['mealType'] = mealType;
@@ -135,7 +117,6 @@ class DietService {
}
}
/// 问诊服务
class ConsultationService {
final ApiClient _api;
ConsultationService(this._api);
@@ -145,31 +126,8 @@ class ConsultationService {
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
Future<Map<String, dynamic>> getQuota() async {
final res = await _api.get('/api/user/consultation-quota');
return res.data['data'] ?? {};
}
Future<Map<String, dynamic>> createConsultation(String doctorId) async {
final res = await _api.post('/api/consultations', data: {'doctorId': doctorId});
return res.data['data'];
}
Future<List<Map<String, dynamic>>> getMessages(String consultationId, {String? after}) async {
final params = <String, dynamic>{};
if (after != null) params['after'] = after;
final res = await _api.get('/api/consultations/$consultationId/messages', queryParameters: params);
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
Future<void> sendMessage(String consultationId, String content) async {
await _api.post('/api/consultations/$consultationId/messages', data: {'content': content});
}
}
/// 随访服务
class FollowUpService {
final ApiClient _api;
FollowUpService(this._api);
@@ -181,7 +139,6 @@ class FollowUpService {
}
}
/// 运动服务
class ExerciseService {
final ApiClient _api;
ExerciseService(this._api);
@@ -191,10 +148,6 @@ class ExerciseService {
return res.data['data'];
}
Future<void> createPlan(Map<String, dynamic> data) async {
await _api.post('/api/exercise-plans', data: data);
}
Future<void> createPlanSimple(Map<String, dynamic> data) async {
await _api.post('/api/exercise-plans', data: data);
}

View File

@@ -1,242 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
enum AppButtonVariant { primary, secondary, outline, ghost, danger, gradientOutline }
class AppButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final AppButtonVariant variant;
final Color? color;
final LinearGradient? gradient;
final bool loading;
final double height;
final bool expand;
const AppButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.variant = AppButtonVariant.primary,
this.color,
this.gradient,
this.loading = false,
this.height = 50,
this.expand = true,
});
@override
Widget build(BuildContext context) {
final enabled = onPressed != null && !loading;
final baseColor = color ?? AppColors.primary;
final child = _ButtonContent(
label: label,
icon: icon,
loading: loading,
foreground: _foreground(baseColor),
);
if (variant == AppButtonVariant.gradientOutline) {
return _GradientOutlineButton(
label: label,
icon: icon,
loading: loading,
enabled: enabled,
height: height,
expand: expand,
gradient: gradient ?? AppColors.actionOutlineGradient,
onPressed: onPressed,
);
}
final decoration = _decoration(baseColor, enabled);
final width = expand ? double.infinity : null;
return SizedBox(
height: height,
width: width,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: AppRadius.lgBorder,
child: Ink(
decoration: decoration,
child: Center(child: child),
),
),
),
);
}
Color _foreground(Color baseColor) {
return switch (variant) {
AppButtonVariant.primary => Colors.white,
AppButtonVariant.secondary => baseColor,
AppButtonVariant.outline => baseColor,
AppButtonVariant.ghost => baseColor,
AppButtonVariant.danger => variant == AppButtonVariant.danger && color == null
? AppColors.error
: Colors.white,
AppButtonVariant.gradientOutline => AppColors.textPrimary,
};
}
BoxDecoration _decoration(Color baseColor, bool enabled) {
final disabledColor = const Color(0xFFE5E7EB);
return switch (variant) {
AppButtonVariant.primary => BoxDecoration(
color: enabled ? baseColor : disabledColor,
borderRadius: AppRadius.lgBorder,
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
),
AppButtonVariant.secondary => BoxDecoration(
color: enabled ? baseColor.withValues(alpha: 0.10) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.outline => BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.lgBorder,
border: Border.all(color: enabled ? baseColor.withValues(alpha: 0.38) : disabledColor),
),
AppButtonVariant.ghost => BoxDecoration(
color: enabled ? baseColor.withValues(alpha: 0.08) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.danger => BoxDecoration(
color: enabled ? (color ?? AppColors.error) : disabledColor,
borderRadius: AppRadius.lgBorder,
),
AppButtonVariant.gradientOutline => const BoxDecoration(),
};
}
}
class _ButtonContent extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final Color foreground;
const _ButtonContent({
required this.label,
required this.icon,
required this.loading,
required this.foreground,
});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (loading)
SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: foreground),
)
else if (icon != null)
Icon(icon, size: 19, color: foreground),
if (loading || icon != null) const SizedBox(width: 8),
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: AppTextStyles.button.copyWith(color: foreground),
),
),
],
);
}
}
class _GradientOutlineButton extends StatelessWidget {
final String label;
final IconData? icon;
final bool loading;
final bool enabled;
final double height;
final bool expand;
final LinearGradient gradient;
final VoidCallback? onPressed;
const _GradientOutlineButton({
required this.label,
required this.icon,
required this.loading,
required this.enabled,
required this.height,
required this.expand,
required this.gradient,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
width: expand ? double.infinity : null,
padding: const EdgeInsets.all(1.5),
decoration: BoxDecoration(
gradient: enabled ? gradient : null,
color: enabled ? null : AppColors.borderLight,
borderRadius: AppRadius.lgBorder,
boxShadow: enabled ? AppShadows.soft : AppShadows.none,
),
child: Material(
color: Colors.white,
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
child: InkWell(
onTap: enabled ? onPressed : null,
borderRadius: BorderRadius.circular(AppRadius.lg - 1.5),
child: Center(
child: _ButtonContent(
label: label,
icon: icon,
loading: loading,
foreground: AppColors.textPrimary,
),
),
),
),
);
}
}
class AppIconTile extends StatelessWidget {
final IconData icon;
final Color color;
final Color backgroundColor;
final double size;
final double iconSize;
final BorderRadius? borderRadius;
const AppIconTile({
super.key,
required this.icon,
required this.color,
required this.backgroundColor,
this.size = 44,
this.iconSize = 22,
this.borderRadius,
});
@override
Widget build(BuildContext context) => Container(
width: size,
height: size,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: borderRadius ?? AppRadius.mdBorder,
border: Border.all(color: color.withValues(alpha: 0.16)),
),
child: Icon(icon, size: iconSize, color: color),
);
}

View File

@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_theme.dart';
class AppCard extends StatelessWidget {
final Widget child;
final EdgeInsetsGeometry? padding;
final EdgeInsetsGeometry? margin;
final VoidCallback? onTap;
final Color? backgroundColor;
final BorderRadius? borderRadius;
final Border? border;
const AppCard({
super.key,
required this.child,
this.padding = const EdgeInsets.all(AppTheme.sLg),
this.margin,
this.onTap,
this.backgroundColor,
this.borderRadius,
this.border,
});
@override
Widget build(BuildContext context) {
final radius = borderRadius ?? BorderRadius.circular(AppTheme.rLg);
final card = Container(
margin:
margin ??
const EdgeInsets.symmetric(
horizontal: AppTheme.sLg,
vertical: AppTheme.sSm,
),
padding: padding,
decoration: BoxDecoration(
gradient: backgroundColor == null ? AppColors.surfaceGradient : null,
color: backgroundColor,
borderRadius: radius,
border: border ?? Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: child,
);
if (onTap == null) return card;
return InkWell(onTap: onTap, borderRadius: radius, child: card);
}
}

View File

@@ -1,95 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
class AppMenuItem extends StatelessWidget {
final IconData icon;
final String title;
final String? subtitle;
final String? trailing;
final VoidCallback? onTap;
const AppMenuItem({
super.key,
required this.icon,
required this.title,
this.subtitle,
this.trailing,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 5),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [AppColors.primaryLight, AppColors.infoLight],
),
borderRadius: BorderRadius.circular(13),
),
child: Icon(icon, size: 20, color: AppColors.primaryDark),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
if (subtitle != null && subtitle!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
subtitle!,
style: const TextStyle(
fontSize: 13,
color: AppColors.textSecondary,
),
),
),
],
),
),
if (trailing != null && trailing!.isNotEmpty)
Text(
trailing!,
style: const TextStyle(
fontSize: 14,
color: AppColors.textSecondary,
),
),
const SizedBox(width: 4),
const Icon(
Icons.chevron_right,
size: 20,
color: AppColors.textHint,
),
],
),
),
);
}
}

View File

@@ -1,6 +1,5 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
class AppStatusBadge extends StatelessWidget {
@@ -38,13 +37,3 @@ class AppStatusBadge extends StatelessWidget {
),
);
}
class AppStatusColors {
AppStatusColors._();
static Color done = AppColors.success;
static Color warning = AppColors.warning;
static Color danger = AppColors.error;
static Color info = AppColors.health;
}

View File

@@ -1,49 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_theme.dart';
class AppTabChip extends StatelessWidget {
final String label;
final bool selected;
final VoidCallback onTap;
const AppTabChip({
super.key,
required this.label,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
margin: const EdgeInsets.only(right: AppTheme.sSm),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9),
decoration: BoxDecoration(
gradient: selected
? AppColors.primaryGradient
: AppColors.surfaceGradient,
color: selected ? null : Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rPill),
border: Border.all(
color: selected ? AppColors.primaryLight : AppColors.borderLight,
),
boxShadow: selected
? AppColors.buttonShadow
: AppColors.cardShadowLight,
),
child: Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: selected ? Colors.white : AppColors.textSecondary,
),
),
),
);
}
}

View File

@@ -1,117 +1,13 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
/// 渐变边框按钮(白色底+彩色渐变边框)
class GradientBorderButton extends StatelessWidget {
final String text;
final IconData? icon;
final VoidCallback? onTap;
final double height;
final bool isSuccess;
const GradientBorderButton({
super.key,
required this.text,
this.icon,
this.onTap,
this.height = 52,
this.isSuccess = false,
});
@override
Widget build(BuildContext context) {
return Container(
height: height,
decoration: BoxDecoration(
gradient: isSuccess
? AppColors.calmHealthGradient
: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(14),
boxShadow: AppColors.buttonShadow,
),
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (icon != null) ...[
Icon(icon, size: 22, color: AppColors.textOnGradient),
const SizedBox(width: 8),
],
Text(
text,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
color: AppColors.textOnGradient,
),
),
],
),
),
),
);
}
}
/// 卡片内的小操作按钮
class CardActionButton extends StatelessWidget {
final String label;
final IconData icon;
final VoidCallback? onTap;
final Color? iconColor;
const CardActionButton({
super.key,
required this.label,
required this.icon,
this.onTap,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
gradient: AppColors.surfaceGradient,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight),
boxShadow: AppColors.cardShadowLight,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 18, color: iconColor ?? AppColors.primary),
const SizedBox(width: 6),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textPrimary,
),
),
],
),
),
);
}
}
/// 滑动删除组件左滑露出红色删除背景达30%阈值锁定,点击确认删除
/// [margin] 控制卡片之间的间距red背景与child完全对齐
class SwipeDeleteTile extends StatefulWidget {
final Widget child;
final VoidCallback onDelete;
final VoidCallback? onTap;
final EdgeInsetsGeometry margin;
const SwipeDeleteTile({
super.key,
required this.child,
@@ -119,6 +15,7 @@ class SwipeDeleteTile extends StatefulWidget {
this.onTap,
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
});
@override
State<SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
@@ -127,18 +24,14 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
with SingleTickerProviderStateMixin {
double _dx = 0;
static const _maxSlide = 80.0;
static const _threshold = 24.0; // 30% of 80
static const _threshold = 24.0;
void _onDragUpdate(DragUpdateDetails d) {
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
}
void _onDragEnd(DragEndDetails d) {
if (_dx < -_threshold) {
setState(() => _dx = -_maxSlide);
} else {
setState(() => _dx = 0);
}
setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0);
}
@override
@@ -191,34 +84,3 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
);
}
}
/// 功能区小图标容器
class IconBox extends StatelessWidget {
final IconData icon;
final double size;
final Color? backgroundColor;
final Color? iconColor;
const IconBox({
super.key,
required this.icon,
this.size = 44,
this.backgroundColor,
this.iconColor,
});
@override
Widget build(BuildContext context) {
return Container(
width: size,
height: size,
decoration: BoxDecoration(
gradient: backgroundColor == null ? AppColors.calmHealthGradient : null,
color: backgroundColor,
borderRadius: BorderRadius.circular(size / 3),
boxShadow: AppColors.cardShadowLight,
),
child: Icon(icon, size: size * 0.55, color: iconColor ?? Colors.white),
);
}
}

View File

@@ -3,8 +3,14 @@ import 'package:flutter/material.dart';
class DrawerShell extends StatelessWidget {
final double widthFactor;
final Widget child;
final bool showBackground;
const DrawerShell({super.key, required this.child, this.widthFactor = 0.84});
const DrawerShell({
super.key,
required this.child,
this.widthFactor = 0.84,
this.showBackground = true,
});
@override
Widget build(BuildContext context) {
@@ -16,29 +22,32 @@ class DrawerShell extends StatelessWidget {
borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)),
child: Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/branding/drawer_background_v1.png',
fit: BoxFit.cover,
alignment: Alignment.centerLeft,
if (showBackground) ...[
Positioned.fill(
child: Image.asset(
'assets/branding/drawer_background_v1.png',
fit: BoxFit.cover,
alignment: Alignment.centerLeft,
),
),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.42),
Colors.white.withValues(alpha: 0.72),
],
stops: const [0.0, 0.48, 1.0],
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.42),
Colors.white.withValues(alpha: 0.72),
],
stops: const [0.0, 0.48, 1.0],
),
),
),
),
),
] else
const Positioned.fill(child: ColoredBox(color: Colors.white)),
child,
],
),

View File

@@ -1,7 +1,7 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_theme.dart';
class EnterpriseStat {
final String label;
@@ -177,97 +177,3 @@ class _HeaderStat extends StatelessWidget {
);
}
}
class EnterpriseSectionCard extends StatelessWidget {
final String? title;
final IconData? icon;
final Color color;
final Widget child;
final EdgeInsetsGeometry padding;
final Widget? trailing;
const EnterpriseSectionCard({
super.key,
this.title,
this.icon,
required this.color,
required this.child,
this.trailing,
this.padding = const EdgeInsets.all(16),
});
@override
Widget build(BuildContext context) {
return Container(
padding: padding,
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: AppColors.border, width: 1.1),
boxShadow: [AppTheme.shadowLight],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (title != null) ...[
Row(
children: [
if (icon != null) ...[
Container(
width: 34,
height: 34,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 19, color: color),
),
const SizedBox(width: 10),
],
Expanded(
child: Text(
title!,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
),
?trailing,
],
),
const SizedBox(height: 14),
],
child,
],
),
);
}
}
class EnterpriseFab extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final Color color;
const EnterpriseFab({
super.key,
required this.onPressed,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return FloatingActionButton(
onPressed: onPressed,
backgroundColor: color,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
child: Icon(icon, size: 26, color: Colors.white),
);
}
}

View File

@@ -21,31 +21,100 @@ class HealthDrawer extends ConsumerWidget {
return DrawerShell(
widthFactor: 0.9,
child: SafeArea(
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverPadding(
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
sliver: SliverList.list(
showBackground: false,
child: CustomScrollView(
physics: const BouncingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: _DrawerHero(
user: auth.user,
latestHealth: latestHealth,
ref: ref,
),
),
SliverToBoxAdapter(
child: Container(
padding: const EdgeInsets.fromLTRB(14, 10, 14, 18),
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(28)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_AccountHeader(user: auth.user, ref: ref),
const SizedBox(height: 18),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
const SizedBox(height: 18),
_NavigationSection(ref: ref),
const SizedBox(height: 18),
const SizedBox(height: 14),
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
const SizedBox(height: 14),
_HistorySection(ref: ref),
],
),
),
],
),
),
],
),
);
}
}
class _DrawerHero extends StatelessWidget {
final dynamic user;
final AsyncValue<Map<String, dynamic>> latestHealth;
final WidgetRef ref;
const _DrawerHero({
required this.user,
required this.latestHealth,
required this.ref,
});
@override
Widget build(BuildContext context) {
final topPadding = MediaQuery.paddingOf(context).top;
return Stack(
children: [
Positioned.fill(
child: Image.asset(
'assets/branding/drawer_background_v1.png',
fit: BoxFit.cover,
alignment: Alignment.topLeft,
),
),
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.white.withValues(alpha: 0.12),
Colors.white.withValues(alpha: 0.35),
Colors.white.withValues(alpha: 0.82),
],
stops: const [0.0, 0.58, 1.0],
),
),
),
),
Padding(
padding: EdgeInsets.fromLTRB(16, topPadding + 16, 16, 12),
child: Column(
children: [
_AccountHeader(user: user, ref: ref),
const SizedBox(height: 16),
_HealthDashboard(latestHealth: latestHealth, ref: ref),
],
),
),
],
);
}
}
class _AccountHeader extends StatelessWidget {
final dynamic user;
final WidgetRef ref;
@@ -360,13 +429,14 @@ class _NavigationSection extends StatelessWidget {
return _LightSection(
title: '常用功能',
child: GridView.builder(
padding: EdgeInsets.zero,
itemCount: items.length,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 2,
mainAxisExtent: 48,
mainAxisSpacing: 8,
crossAxisCount: 4,
mainAxisExtent: 82,
mainAxisSpacing: 7,
crossAxisSpacing: 8,
),
itemBuilder: (context, index) {
@@ -412,38 +482,42 @@ class _NavTile extends StatelessWidget {
return InkWell(
onTap: onTap,
borderRadius: AppRadius.mdBorder,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.54),
borderRadius: AppRadius.mdBorder,
),
child: Row(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Container(
width: 30,
height: 30,
width: 52,
height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: _colors,
),
borderRadius: AppRadius.smBorder,
borderRadius: AppRadius.mdBorder,
boxShadow: [
BoxShadow(
color: _colors.last.withValues(alpha: 0.18),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
child: Icon(item.icon, color: Colors.white, size: 19),
child: Icon(item.icon, color: Colors.white, size: 26),
),
const SizedBox(width: 8),
Expanded(
child: Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
const SizedBox(height: 6),
Text(
_title,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.1,
),
),
],
@@ -471,8 +545,8 @@ class _LightSection extends StatelessWidget {
child: Text(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
fontSize: 18,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
@@ -480,7 +554,7 @@ class _LightSection extends StatelessWidget {
],
),
),
const SizedBox(height: 10),
const SizedBox(height: 2),
child,
],
);
@@ -516,10 +590,6 @@ class _Panel extends StatelessWidget {
],
),
borderRadius: AppRadius.xlBorder,
border: Border.all(
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: AppShadows.soft,
),
child: Column(
@@ -532,7 +602,7 @@ class _Panel extends StatelessWidget {
title,
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w900,
fontWeight: FontWeight.w800,
color: titleColor,
),
),
@@ -636,24 +706,53 @@ class _NavItem {
required this.colors,
});
}
class _HistorySection extends ConsumerWidget {
class _HistorySection extends ConsumerStatefulWidget {
final WidgetRef ref;
const _HistorySection({required this.ref});
static const int _previewCount = 5;
@override
Widget build(BuildContext context, WidgetRef _) {
ConsumerState<_HistorySection> createState() => _HistorySectionState();
}
class _HistorySectionState extends ConsumerState<_HistorySection> {
String? _selectedId;
bool _selecting = false;
bool _deleting = false;
void _enterSelect(String id) {
setState(() {
_selecting = true;
_selectedId = id;
});
}
void _toggleSelect(String id) {
setState(() {
if (_selectedId == id) {
_selectedId = null;
_selecting = false;
} else {
_selectedId = id;
}
});
}
void _exitSelect() {
setState(() {
_selecting = false;
_selectedId = null;
});
}
@override
Widget build(BuildContext context) {
final async = ref.watch(conversationHistoryProvider);
return _Panel(
return _LightSection(
title: '对话记录',
trailing: _HistoryActions(ref: ref, async: async),
backgroundGradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)],
),
child: async.when(
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
@@ -682,204 +781,268 @@ class _HistorySection extends ConsumerWidget {
),
);
}
final preview = list.take(_previewCount).toList();
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
onTap: () async {
Navigator.of(context).maybePop(); // 关闭侧边栏
await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
},
onDelete: () async {
try {
await ref
.read(conversationHistoryProvider.notifier)
.deleteOne(preview[i].id);
} catch (_) {
// 失败时 provider 已回滚状态UI 自然恢复
}
},
),
if (list.length > _previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
final preview = list.take(_HistorySection._previewCount).toList();
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 48),
child: Stack(
clipBehavior: Clip.none,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < preview.length; i++)
_DrawerHistoryTile(
item: preview[i],
isLast: i == preview.length - 1,
selecting: _selecting,
selected: _selectedId == preview[i].id,
onTap: () async {
if (_selecting) {
_toggleSelect(preview[i].id);
return;
}
final error = await ref
.read(chatProvider.notifier)
.loadConversation(preview[i].id);
if (!context.mounted) return;
if (error != null) {
AppToast.show(
context,
error,
type: AppToastType.error,
);
return;
}
Navigator.of(context).maybePop();
},
onLongPress: () => _enterSelect(preview[i].id),
),
if (list.length > _HistorySection._previewCount) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: InkWell(
onTap: () {
Navigator.of(context).maybePop();
pushRoute(ref, 'conversationHistory');
},
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: 6,
),
child: Text(
'查看全部',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: AppColors.textSecondary,
),
),
),
),
),
],
],
),
if (_selecting && _selectedId != null)
Positioned(
top: -46,
right: 2,
child: _HistorySelectionBar(
deleting: _deleting,
onCancel: _exitSelect,
onDelete: () => _deleteSelected(context),
),
),
),
],
],
),
);
},
),
);
}
Future<void> _deleteSelected(BuildContext context) async {
final id = _selectedId;
if (id == null || _deleting) return;
setState(() => _deleting = true);
try {
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
_exitSelect();
} catch (_) {
if (context.mounted) {
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
} finally {
if (mounted) setState(() => _deleting = false);
}
}
}
class _HistoryActions extends StatelessWidget {
final WidgetRef ref;
final AsyncValue<List<ConversationListItem>> async;
const _HistoryActions({required this.ref, required this.async});
class _HistorySelectionBar extends StatelessWidget {
final bool deleting;
final VoidCallback onCancel;
final VoidCallback onDelete;
const _HistorySelectionBar({
required this.deleting,
required this.onCancel,
required this.onDelete,
});
@override
Widget build(BuildContext context) {
final hasItems = (async.asData?.value.isNotEmpty ?? false);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.refresh_rounded,
size: 18,
color: Color(0xFF6D28D9),
),
),
return Align(
alignment: Alignment.centerRight,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(18),
border: Border.all(color: const Color(0xFFE5E7EB)),
boxShadow: AppColors.cardShadowLight,
),
if (hasItems)
InkWell(
onTap: () => _confirmClearAll(context, ref),
borderRadius: BorderRadius.circular(999),
child: const Padding(
padding: EdgeInsets.all(6),
child: Icon(
Icons.delete_sweep_rounded,
size: 18,
color: AppColors.error,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
_HistoryActionButton(
icon: Icons.delete_outline_rounded,
label: deleting ? '删除中' : '删除',
color: AppColors.error,
onTap: deleting ? null : onDelete,
),
),
],
);
}
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('清空全部历史'),
content: const Text('清空后无法恢复,确认继续?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('取消'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
style: TextButton.styleFrom(foregroundColor: AppColors.error),
child: const Text('清空'),
),
],
const SizedBox(width: 8),
Container(width: 1, height: 22, color: const Color(0xFFE5E7EB)),
const SizedBox(width: 8),
_HistoryActionButton(
icon: Icons.close_rounded,
label: '取消',
color: AppColors.textSecondary,
onTap: onCancel,
),
],
),
),
);
}
}
class _HistoryActionButton extends StatelessWidget {
final IconData icon;
final String label;
final Color color;
final VoidCallback? onTap;
const _HistoryActionButton({
required this.icon,
required this.label,
required this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: 18,
color: onTap == null ? AppColors.textHint : color,
),
const SizedBox(width: 5),
Text(
label,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w800,
color: onTap == null ? AppColors.textHint : color,
),
),
],
),
),
);
if (ok != true) return;
try {
await ref.read(conversationHistoryProvider.notifier).clearAll();
} catch (_) {
if (context.mounted) {
AppToast.show(
context,
'清空失败,请稍后重试',
type: AppToastType.error,
);
}
}
}
}
class _DrawerHistoryTile extends StatelessWidget {
final ConversationListItem item;
final bool isLast;
final bool selecting;
final bool selected;
final VoidCallback onTap;
final VoidCallback onDelete;
final VoidCallback onLongPress;
const _DrawerHistoryTile({
required this.item,
required this.isLast,
required this.selecting,
required this.selected,
required this.onTap,
required this.onDelete,
required this.onLongPress,
});
@override
Widget build(BuildContext context) {
return Dismissible(
key: ValueKey('drawer-${item.id}'),
direction: DismissDirection.endToStart,
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 14),
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
decoration: BoxDecoration(
color: AppColors.error,
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete_outline, color: Colors.white),
),
confirmDismiss: (_) async {
onDelete();
return true;
},
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(14),
child: Container(
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.72),
borderRadius: BorderRadius.circular(14),
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
height: 1.2,
return InkWell(
onTap: onTap,
onLongPress: onLongPress,
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: selected
? BoxDecoration(
color: const Color(0xFFF1F3F7),
borderRadius: BorderRadius.circular(8),
)
: null,
child: Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 11),
child: Row(
children: [
Expanded(
child: Text(
_displaySummary(item),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: selected
? AppColors.textSecondary
: AppColors.textPrimary,
height: 1.2,
),
),
),
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
],
),
const SizedBox(width: 10),
Text(
_shortDate(item.updatedAt),
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: AppColors.textHint,
),
),
if (!isLast)
const Divider(
height: 1,
thickness: 0.7,
color: Color(0xFFE8ECF2),
),
],
),
],
),
),
);

View File

@@ -0,0 +1,59 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:health_app/providers/chat_provider.dart';
void main() {
test('delayed agent welcome is skipped after session reset', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await notifier.resetSession();
await Future<void>.delayed(const Duration(milliseconds: 450));
final messages = container.read(chatProvider).messages;
expect(messages, isEmpty);
});
test('agent capsule inserts welcome when session is unchanged', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
container
.read(chatProvider.notifier)
.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 450));
final messages = container.read(chatProvider).messages;
expect(messages.map((m) => m.type), contains(MessageType.agentWelcome));
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
});
test(
'pending agent capsule ignores later taps until welcome appears',
() async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 120));
notifier.triggerAgent(ActiveAgent.medication, '药管家');
await Future<void>.delayed(const Duration(milliseconds: 450));
final welcomeAgents = container
.read(chatProvider)
.messages
.where((m) => m.type == MessageType.agentWelcome)
.map((m) => m.metadata?['agent'])
.toList();
expect(welcomeAgents, ['diet']);
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
},
);
}

View File

@@ -0,0 +1,37 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:health_app/models/ble_device.dart';
import 'package:health_app/pages/chart/trend_page.dart';
import 'package:health_app/pages/remaining_pages.dart';
import 'package:health_app/services/health_ble_service.dart';
void main() {
test('unknown trend metric falls back to blood pressure', () {
expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure');
expect(normalizeTrendMetricType(null), 'blood_pressure');
expect(normalizeTrendMetricType('spo2'), 'spo2');
});
test('static compliance pages include collection and sdk lists', () {
expect(staticTextTitle('personalInfoList'), '个人信息收集清单');
expect(staticTextContent('personalInfoList'), contains('健康数据'));
expect(staticTextTitle('thirdPartySdkList'), '第三方 SDK 共享清单');
expect(staticTextContent('thirdPartySdkList'), contains('AI'));
});
test('ble sync is only implemented for blood pressure devices', () {
expect(
HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure),
true,
);
expect(HealthBleService.isSyncImplemented(BleDeviceType.glucose), false);
expect(
HealthBleService.isSyncImplemented(BleDeviceType.weightScale),
false,
);
expect(
HealthBleService.isSyncImplemented(BleDeviceType.pulseOximeter),
false,
);
});
}