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:
@@ -2,12 +2,6 @@ using Health.Domain.Entities;
|
||||
|
||||
namespace Health.Application.Exercises;
|
||||
|
||||
public sealed record ExercisePlanItemInput(
|
||||
DateOnly ScheduledDate,
|
||||
string? ExerciseType,
|
||||
int DurationMinutes,
|
||||
bool IsRestDay);
|
||||
|
||||
public sealed record ExercisePlanCreateRequest(
|
||||
DateOnly StartDate,
|
||||
DateOnly EndDate,
|
||||
@@ -47,7 +41,6 @@ public interface IExerciseService
|
||||
{
|
||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct);
|
||||
Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct);
|
||||
Task<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
Task<bool> DeleteAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||
|
||||
@@ -41,26 +41,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
public async Task<Guid> CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList<ExercisePlanItemInput> items, CancellationToken ct)
|
||||
{
|
||||
foreach (var item in items)
|
||||
ValidateDuration(item.DurationMinutes);
|
||||
|
||||
var normalizedEnd = endDate < startDate ? startDate : endDate;
|
||||
var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime);
|
||||
foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First()))
|
||||
{
|
||||
plan.Items.Add(new ExercisePlanItem
|
||||
{
|
||||
Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate,
|
||||
ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(),
|
||||
DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30,
|
||||
IsRestDay = item.IsRestDay,
|
||||
});
|
||||
}
|
||||
await SaveNewAsync(plan, ct);
|
||||
return plan.Id;
|
||||
}
|
||||
|
||||
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
||||
private static void ValidateDuration(int durationMinutes)
|
||||
|
||||
@@ -37,7 +37,6 @@ public sealed record HealthArchiveUpdateRequest(
|
||||
public interface IHealthArchiveService
|
||||
{
|
||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||
}
|
||||
|
||||
@@ -12,13 +12,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH
|
||||
return archive == null ? null : ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
await _archives.SaveChangesAsync(ct);
|
||||
return ToDto(archive);
|
||||
}
|
||||
|
||||
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||
{
|
||||
var archive = await GetOrCreateEntityAsync(userId, ct);
|
||||
|
||||
@@ -33,21 +33,17 @@ public static class MedicationAgentHandler
|
||||
};
|
||||
|
||||
public static List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||
|
||||
public static async Task<object> Execute(
|
||||
string toolName,
|
||||
JsonElement args,
|
||||
AppDbContext db,
|
||||
Guid userId,
|
||||
IMedicationService? medications = null,
|
||||
IMedicationService medications,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
return toolName switch
|
||||
{
|
||||
"manage_medication" => medications != null
|
||||
? await ExecuteManageMedication(medications, userId, args, ct)
|
||||
: await ExecuteManageMedication(db, userId, args),
|
||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
||||
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||
};
|
||||
}
|
||||
|
||||
@@ -111,82 +107,6 @@ public static class MedicationAgentHandler
|
||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
||||
}
|
||||
|
||||
private static async Task<object> ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
|
||||
return action switch
|
||||
{
|
||||
"create" => await CreateMedication(db, userId, args),
|
||||
"query" => await QueryMedications(db, userId),
|
||||
"confirm" => await ConfirmMedication(db, userId, args),
|
||||
_ => new { success = false, message = $"未知操作: {action}" }
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> CreateMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var name = args.TryGetProperty("name", out var n) ? n.GetString()! : "";
|
||||
var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null;
|
||||
var frequency = ReadFrequency(args);
|
||||
var times = ReadTimes(args);
|
||||
var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0;
|
||||
var startDate = ReadStartDate(args);
|
||||
|
||||
var med = new Medication
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
UserId = userId,
|
||||
Name = name,
|
||||
Dosage = dosage,
|
||||
Frequency = frequency,
|
||||
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
|
||||
Source = MedicationSource.AiEntry,
|
||||
IsActive = true,
|
||||
StartDate = startDate,
|
||||
EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null,
|
||||
};
|
||||
db.Medications.Add(med);
|
||||
await db.SaveChangesAsync();
|
||||
|
||||
var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00";
|
||||
return new
|
||||
{
|
||||
success = true,
|
||||
medication_id = med.Id,
|
||||
name = med.Name,
|
||||
dosage = med.Dosage,
|
||||
frequency = med.Frequency.ToString(),
|
||||
time = timeLabels,
|
||||
start_date = med.StartDate?.ToString("yyyy-MM-dd"),
|
||||
duration_days = durationDays,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<object> QueryMedications(AppDbContext db, Guid userId)
|
||||
{
|
||||
var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive)
|
||||
.Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync();
|
||||
return new { count = meds.Count, medications = meds };
|
||||
}
|
||||
|
||||
private static async Task<object> ConfirmMedication(AppDbContext db, Guid userId, JsonElement args)
|
||||
{
|
||||
var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty;
|
||||
var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId);
|
||||
if (med == null) return new { success = false, message = "药品不存在" };
|
||||
|
||||
db.MedicationLogs.Add(new MedicationLog
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
MedicationId = medId,
|
||||
UserId = userId,
|
||||
Status = MedicationLogStatus.Taken,
|
||||
ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
|
||||
ConfirmedAt = DateTime.UtcNow,
|
||||
});
|
||||
await db.SaveChangesAsync();
|
||||
return new { success = true };
|
||||
}
|
||||
|
||||
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ public sealed class AiToolExecutionService(
|
||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct),
|
||||
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct),
|
||||
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct),
|
||||
|
||||
@@ -818,5 +818,3 @@ public static class AiChatEndpoints
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
public sealed record ChatRequest(string Message, string? ConversationId);
|
||||
|
||||
@@ -184,4 +184,5 @@ public sealed class PersistencePipelineTests
|
||||
new DbContextOptionsBuilder<AppDbContext>()
|
||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||
.Options);
|
||||
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 =
|
||||
@@ -546,9 +573,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final d = _filtered[idx]['date'] as DateTime;
|
||||
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||
final prevIdx = idx - xInterval.toInt();
|
||||
final showMonth = idx == 0 ||
|
||||
final showMonth =
|
||||
idx == 0 ||
|
||||
prevIdx < 0 ||
|
||||
(_filtered[prevIdx]['date'] as DateTime).month !=
|
||||
(_filtered[prevIdx]['date'] as DateTime)
|
||||
.month !=
|
||||
d.month;
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -594,7 +623,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
dotData: FlDotData(
|
||||
show: spots.length <= 30,
|
||||
getDotPainter: (spot, _, _, _) {
|
||||
final isSelected = spot.x.toInt() == _selectedIdx;
|
||||
final isSelected =
|
||||
spot.x.toInt() == _selectedIdx;
|
||||
if (isSelected) {
|
||||
return FlDotCirclePainter(
|
||||
radius: 5.5,
|
||||
@@ -636,8 +666,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final spots = response?.lineBarSpots;
|
||||
if (spots != null && spots.isNotEmpty) {
|
||||
final idx = spots.first.x.toInt();
|
||||
setState(() =>
|
||||
_selectedIdx = (_selectedIdx == idx) ? null : idx);
|
||||
setState(
|
||||
() => _selectedIdx = (_selectedIdx == idx)
|
||||
? null
|
||||
: idx,
|
||||
);
|
||||
} else {
|
||||
setState(() => _selectedIdx = null);
|
||||
}
|
||||
@@ -666,8 +699,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828)
|
||||
.withValues(alpha: 0.14),
|
||||
color: const Color(
|
||||
0xFF101828,
|
||||
).withValues(alpha: 0.14),
|
||||
blurRadius: 16,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
@@ -731,7 +765,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
..._filtered.reversed.take(30).map((r) {
|
||||
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() ?? '';
|
||||
@@ -739,7 +786,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
final isSelected = idx == _selectedIdx;
|
||||
String display;
|
||||
if (_isBP) {
|
||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
display =
|
||||
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||
} else {
|
||||
display = '${r['value'] ?? '--'}';
|
||||
}
|
||||
@@ -747,21 +795,22 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
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),
|
||||
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);
|
||||
_allRecords.removeWhere(
|
||||
(x) => x['id']?.toString() == id,
|
||||
);
|
||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||
_selectedIdx = null;
|
||||
});
|
||||
@@ -773,26 +822,19 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
if (idx < 0) return;
|
||||
setState(() =>
|
||||
_selectedIdx = isSelected ? null : idx);
|
||||
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)
|
||||
? _color.withValues(alpha: 0.10)
|
||||
: 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: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 14,
|
||||
vertical: 12,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -801,7 +843,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withAlpha(20),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
@@ -814,7 +856,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||
@@ -845,7 +888,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
borderRadius: AppRadius.xsBorder,
|
||||
),
|
||||
child: const Text(
|
||||
'异常',
|
||||
@@ -859,10 +902,25 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 66),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -206,6 +206,17 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
name: _deviceNameOf(result),
|
||||
)
|
||||
.timeout(const Duration(seconds: 15));
|
||||
if (!HealthBleService.isSyncImplemented(boundDevice.type)) {
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'${boundDevice.type.label}数据同步暂未开通,暂不绑定',
|
||||
type: AppToastType.info,
|
||||
);
|
||||
unawaited(_startScan());
|
||||
}
|
||||
return;
|
||||
}
|
||||
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||
|
||||
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
||||
@@ -223,8 +234,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
||||
await _showBloodPressureDialog(syncResult.reading);
|
||||
}
|
||||
}
|
||||
} else if (mounted) {
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
if (mounted) popRoute(ref);
|
||||
|
||||
@@ -56,7 +56,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
||||
final item = list[index];
|
||||
return _HistoryTile(
|
||||
item: item,
|
||||
onTap: () => _openConversation(ref, item.id),
|
||||
onTap: () => _openConversation(context, ref, item.id),
|
||||
onDelete: () => _deleteOne(context, ref, item.id),
|
||||
);
|
||||
},
|
||||
@@ -66,8 +66,18 @@ class ConversationHistoryPage extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openConversation(WidgetRef ref, String id) async {
|
||||
await ref.read(chatProvider.notifier).loadConversation(id);
|
||||
Future<void> _openConversation(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String id,
|
||||
) async {
|
||||
final error = await ref.read(chatProvider.notifier).loadConversation(id);
|
||||
if (error != null) {
|
||||
if (context.mounted) {
|
||||
AppToast.show(context, error, type: AppToastType.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
|
||||
@@ -78,7 +78,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
final imagePath = _pickedImagePath;
|
||||
if (text.isEmpty && imagePath == null) return;
|
||||
_textCtrl.clear();
|
||||
_focusNode.unfocus();
|
||||
_dismissKeyboard();
|
||||
setState(() => _pickedImagePath = null);
|
||||
if (imagePath != null) {
|
||||
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||
@@ -145,9 +145,9 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
left: 0,
|
||||
top: 0,
|
||||
bottom: 0,
|
||||
width: 44,
|
||||
width: MediaQuery.sizeOf(context).width * 0.8,
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onHorizontalDragStart: (details) {
|
||||
_drawerDragStartX = details.globalPosition.dx;
|
||||
},
|
||||
@@ -255,16 +255,20 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
];
|
||||
|
||||
Widget _buildAgentBar() {
|
||||
final activeAgent = ref.watch(
|
||||
chatProvider.select((state) => state.activeAgent),
|
||||
);
|
||||
return SizedBox(
|
||||
height: 42,
|
||||
height: 46,
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||
itemCount: _agentDefs.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||
itemBuilder: (_, i) {
|
||||
final agent = _agentDefs[i];
|
||||
final visual = _agentVisual(agent);
|
||||
final selected = activeAgent == agent;
|
||||
return GestureDetector(
|
||||
onTap: () => ref
|
||||
.read(chatProvider.notifier)
|
||||
@@ -272,9 +276,14 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.86),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: Colors.white, width: 1.2),
|
||||
border: Border.all(
|
||||
color: selected
|
||||
? AppColors.auraIndigo.withValues(alpha: 0.42)
|
||||
: const Color(0xFFD5DAE4),
|
||||
width: selected ? 1.4 : 1.1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -477,6 +486,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
|
||||
void _showAttachmentPicker(BuildContext context) {
|
||||
_dismissKeyboard();
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
backgroundColor: Colors.white,
|
||||
@@ -496,6 +506,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('拍照'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
_pickImage(ImageSource.camera);
|
||||
},
|
||||
),
|
||||
@@ -507,6 +518,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('从相册选择'),
|
||||
onTap: () {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
_pickImage(ImageSource.gallery);
|
||||
},
|
||||
),
|
||||
@@ -518,6 +530,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
title: const Text('上传 PDF'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
_dismissKeyboard();
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
@@ -532,6 +545,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
.read(chatProvider.notifier)
|
||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||
_textCtrl.clear();
|
||||
_dismissKeyboard();
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
@@ -543,17 +557,25 @@ class _HomePageState extends ConsumerState<HomePage>
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
_dismissKeyboard();
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
imageQuality: 85,
|
||||
);
|
||||
_dismissKeyboard();
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
setState(() => _pickedImagePath = picked.path);
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard());
|
||||
}
|
||||
}
|
||||
|
||||
void _dismissKeyboard() {
|
||||
_focusNode.unfocus();
|
||||
FocusManager.instance.primaryFocus?.unfocus();
|
||||
}
|
||||
|
||||
Future<void> _pickFoodImage(ImageSource source) async {
|
||||
final picked = await ImagePicker().pickImage(
|
||||
source: source,
|
||||
|
||||
@@ -859,13 +859,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: msg.confirmed
|
||||
child: msg.confirmed || msg.isReadOnly
|
||||
? Container(
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.successButtonGradient,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Row(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
@@ -873,10 +873,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
size: 22,
|
||||
color: Colors.white,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'录入成功',
|
||||
style: TextStyle(
|
||||
msg.isReadOnly ? '历史记录' : '录入成功',
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: Colors.white,
|
||||
@@ -1236,9 +1236,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InteractiveViewer(
|
||||
child: path.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: resolvedPath.startsWith('http')
|
||||
child: resolvedPath.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||
),
|
||||
@@ -1270,20 +1268,6 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||
// ignore: unused_element
|
||||
static String _cleanAiText(String text) {
|
||||
var t = text;
|
||||
// 移除 $1、$2 等占位符
|
||||
t = t.replaceAll(RegExp(r'\$\d+'), '');
|
||||
// 移除残留 HTML 标签
|
||||
t = t.replaceAll(RegExp(r'<[^>]+>'), '');
|
||||
// 行首 # 超过 3 个降为 ###
|
||||
t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### ');
|
||||
// 压缩多余空行
|
||||
t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n');
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/// 处理 AI 回复里的 markdown 链接点击:
|
||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||
@@ -2098,77 +2082,3 @@ extension _AgentActionsExt on ActiveAgent {
|
||||
_agentActions[this] ??
|
||||
[const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
|
||||
}
|
||||
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
// 可展开的 AI 建议小组件
|
||||
// ════════════════════════════════════════════════════════════════
|
||||
|
||||
class _ExpandableAdvice extends StatefulWidget {
|
||||
final String advice;
|
||||
const _ExpandableAdvice({required this.advice});
|
||||
|
||||
@override
|
||||
State<_ExpandableAdvice> createState() => _ExpandableAdviceState();
|
||||
}
|
||||
|
||||
class _ExpandableAdviceState extends State<_ExpandableAdvice> {
|
||||
bool _expanded = false;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _expanded = !_expanded),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.lightbulb_outline,
|
||||
size: 19,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'AI 建议',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Icon(
|
||||
_expanded
|
||||
? Icons.keyboard_arrow_up
|
||||
: Icons.keyboard_arrow_down,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_expanded) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
widget.advice,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: Color(0xFF555555),
|
||||
height: 1.6,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -102,7 +102,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...List.generate(list.length, (i) {
|
||||
_MedicationListGroup(
|
||||
children: List.generate(list.length, (i) {
|
||||
final m = list[i];
|
||||
final times =
|
||||
(m['timeOfDay'] as List?)
|
||||
@@ -114,15 +115,70 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
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),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
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: [
|
||||
@@ -131,13 +187,13 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
gradient: isActive
|
||||
? _medVisual.gradient
|
||||
? visual.gradient
|
||||
: AppColors.surfaceGradient,
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(
|
||||
_medVisual.icon,
|
||||
visual.icon,
|
||||
size: 25,
|
||||
color: isActive ? Colors.white : AppTheme.textSub,
|
||||
),
|
||||
@@ -149,26 +205,29 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
m['name']?.toString() ?? '',
|
||||
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: _medVisual.color,
|
||||
),
|
||||
AppStatusBadge(label: '已停', color: visual.color),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${m['dosage'] ?? ''} $times',
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: AppTextStyles.listSubtitle.copyWith(
|
||||
color: AppTheme.textSub,
|
||||
),
|
||||
@@ -176,7 +235,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
const Icon(
|
||||
Icons.chevron_right,
|
||||
size: 21,
|
||||
color: AppColors.textHint,
|
||||
@@ -184,11 +243,16 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 74),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
@@ -240,7 +241,12 @@ class _NotificationCenterPageState
|
||||
if (today.isNotEmpty) ...[
|
||||
const _SectionTitle('今天'),
|
||||
const SizedBox(height: 10),
|
||||
...today.map(_buildDismissible),
|
||||
_NotificationGroup(
|
||||
children: [
|
||||
for (var i = 0; i < today.length; i++)
|
||||
_buildDismissible(today[i], showDivider: i < today.length - 1),
|
||||
],
|
||||
),
|
||||
],
|
||||
if (earlier.isNotEmpty) ...[
|
||||
if (today.isNotEmpty) const SizedBox(height: 18),
|
||||
@@ -251,24 +257,31 @@ 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(
|
||||
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),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
@@ -286,14 +299,39 @@ class _NotificationCenterPageState
|
||||
),
|
||||
),
|
||||
onDismissed: (_) => _delete(item),
|
||||
child: SizedBox(
|
||||
height: 98,
|
||||
child: _NotificationCard(item: item, onTap: () => _open(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(
|
||||
constraints: const BoxConstraints(minHeight: 94),
|
||||
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(
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
Stack(
|
||||
@@ -482,7 +498,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
visual.lightColor,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(
|
||||
color: visual.color.withValues(alpha: 0.22),
|
||||
),
|
||||
@@ -527,7 +543,7 @@ class _NotificationCard extends StatelessWidget {
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Text(
|
||||
visual.label,
|
||||
@@ -589,6 +605,15 @@ class _NotificationCard extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDivider)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(left: 75),
|
||||
child: Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_markdown/flutter_markdown.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/app_colors.dart';
|
||||
import '../core/app_design_tokens.dart';
|
||||
import '../core/app_module_visuals.dart';
|
||||
import '../core/app_theme.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
@@ -721,11 +722,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
} catch (e) {
|
||||
debugPrint('[ExercisePlan] 打卡失败: $e');
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'只能打卡今天的运动任务',
|
||||
type: AppToastType.error,
|
||||
);
|
||||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||
@@ -1304,11 +1301,7 @@ class _ExercisePlanDetailPageState
|
||||
} catch (e) {
|
||||
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
||||
if (mounted) {
|
||||
AppToast.show(
|
||||
context,
|
||||
'只能打卡今天的运动任务',
|
||||
type: AppToastType.error,
|
||||
);
|
||||
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||
@@ -2462,7 +2455,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.errorLight,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
@@ -2506,12 +2499,10 @@ class _Section extends StatelessWidget {
|
||||
};
|
||||
@override
|
||||
Widget build(BuildContext c) => Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -2519,26 +2510,26 @@ class _Section extends StatelessWidget {
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 30,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: _color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Icon(icon, size: 20, color: _color),
|
||||
child: Icon(icon, size: 18, color: _color),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 12),
|
||||
...fields,
|
||||
],
|
||||
),
|
||||
@@ -2572,15 +2563,15 @@ class _F extends StatelessWidget {
|
||||
vertical: 12,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||
),
|
||||
),
|
||||
@@ -2610,7 +2601,7 @@ class _GenderF extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Row(
|
||||
@@ -2641,7 +2632,7 @@ class _GenderChip extends StatelessWidget {
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: selected ? const Color(0xFF2563EB) : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -2685,15 +2676,15 @@ class _F2 extends StatelessWidget {
|
||||
vertical: 8,
|
||||
),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||
),
|
||||
),
|
||||
@@ -2723,7 +2714,7 @@ class _DateF extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -2774,7 +2765,7 @@ class _DateF2 extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
alignment: Alignment.centerLeft,
|
||||
@@ -2824,7 +2815,7 @@ class _AddBtn extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: AppRadius.smBorder,
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: const Row(
|
||||
@@ -3366,13 +3357,119 @@ class _Legend extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
|
||||
const Map<String, String> _extraStaticTextTitles = {
|
||||
'personalInfoList': '个人信息收集清单',
|
||||
'thirdPartySdkList': '第三方 SDK 共享清单',
|
||||
};
|
||||
|
||||
const Map<String, String> _extraStaticTextContents = {
|
||||
'personalInfoList': '''## 个人信息收集清单
|
||||
|
||||
更新日期:2026年7月9日
|
||||
|
||||
本清单用于说明小脉健康 App 在当前功能范围内可能收集的个人信息类型、使用目的和使用场景。实际启用情况以正式部署配置和您实际使用的功能为准。
|
||||
|
||||
### 一、账号与登录信息
|
||||
|
||||
- 信息内容:手机号、登录状态、账号标识、验证码校验结果。
|
||||
- 使用目的:完成注册、登录、身份识别、账号安全校验和账号注销。
|
||||
- 使用场景:登录、注册、刷新登录状态、删除账号。
|
||||
|
||||
### 二、个人资料和健康档案
|
||||
|
||||
- 信息内容:姓名或昵称、性别、出生日期、头像、过敏史、慢病史、家族史、手术信息、饮食禁忌等。
|
||||
- 使用目的:建立健康档案,辅助 AI 健康咨询、医生咨询、报告解读和健康管理。
|
||||
- 使用场景:完善资料、健康档案、AI 对话、医生端查看。
|
||||
|
||||
### 三、健康数据
|
||||
|
||||
- 信息内容:血压、心率、血糖、血氧、体重、记录时间、数据来源、异常状态。
|
||||
- 使用目的:记录和展示健康趋势,生成健康提醒,辅助 AI 分析和医生咨询。
|
||||
- 使用场景:手动录入、蓝牙血压计同步、健康概览、通知提醒、AI 对话确认录入。
|
||||
|
||||
### 四、用药、运动、饮食和日历数据
|
||||
|
||||
- 信息内容:药品名称、剂量、频次、服药时间、打卡记录、运动计划、饮食记录、热量估算、日程提醒。
|
||||
- 使用目的:提供用药管理、运动计划、饮食识别、健康提醒和日历管理。
|
||||
- 使用场景:用药管理、服药打卡、运动计划、饮食拍照识别、通知中心。
|
||||
|
||||
### 五、报告、图片和文件信息
|
||||
|
||||
- 信息内容:您主动上传的检查报告图片、聊天图片、PDF 文件、AI 识别结果、报告摘要和审核状态。
|
||||
- 使用目的:完成报告管理、报告解读、图片识别、AI 对话附件分析。
|
||||
- 使用场景:上传报告、拍照识别、AI 聊天发送照片或 PDF。
|
||||
|
||||
### 六、蓝牙设备信息
|
||||
|
||||
- 信息内容:设备名称、设备标识、设备类型、服务 UUID、最近同步时间、短期去重记录。
|
||||
- 使用目的:发现、绑定和同步支持的健康设备。
|
||||
- 使用场景:蓝牙设备扫描、血压计绑定、血压数据同步。
|
||||
|
||||
### 七、设备、日志和网络信息
|
||||
|
||||
- 信息内容:设备型号、系统版本、App 版本、接口请求状态、错误信息、网络状态。
|
||||
- 使用目的:功能适配、问题排查、服务安全、性能优化和合规审计。
|
||||
- 使用场景:App 使用过程中的接口访问、异常处理、故障排查。
|
||||
''',
|
||||
'thirdPartySdkList': '''## 第三方 SDK 共享清单
|
||||
|
||||
更新日期:2026年7月9日
|
||||
|
||||
本清单用于说明小脉健康 App 当前可能接入或依赖的第三方 SDK、外部接口和服务能力。正式上线前会根据实际启用的服务商、域名和资质信息继续更新。
|
||||
|
||||
### 一、AI 大模型和视觉识别服务
|
||||
|
||||
- 使用目的:AI 健康咨询、报告解读、图片识别、文本生成和内容理解。
|
||||
- 可能共享的信息:您主动输入的对话内容、上传的图片或 PDF、健康档案摘要、需要 AI 分析的健康数据。
|
||||
- 使用场景:AI 对话、拍照识别、上传报告、报告解读。
|
||||
- 说明:实际服务商和接口地址以正式环境配置为准。
|
||||
|
||||
### 二、短信验证服务
|
||||
|
||||
- 使用目的:发送登录或注册验证码。
|
||||
- 可能共享的信息:手机号、验证码发送状态、必要的风控信息。
|
||||
- 使用场景:手机号登录、注册、身份验证。
|
||||
- 说明:当前正式短信服务仍待接入,正式启用后补充服务商信息。
|
||||
|
||||
### 三、文件存储或服务器存储服务
|
||||
|
||||
- 使用目的:保存您主动上传的报告、图片和 PDF 文件。
|
||||
- 可能共享的信息:文件内容、文件名称、上传时间、账号标识。
|
||||
- 使用场景:报告管理、AI 聊天附件、图片识别。
|
||||
- 说明:当前仍处于本地和开发环境阶段,正式服务器和域名确定后补充公网信息。
|
||||
|
||||
### 四、实时通信服务
|
||||
|
||||
- 使用目的:支持医生咨询消息、AI 流式回复和实时状态更新。
|
||||
- 可能共享的信息:消息内容、会话标识、发送时间、账号标识。
|
||||
- 使用场景:AI 对话、医生咨询、通知状态更新。
|
||||
|
||||
### 五、系统能力和开源组件
|
||||
|
||||
- 使用目的:蓝牙扫描、相机/相册选择、文件选择、图表展示、Markdown 渲染、网络请求等 App 基础能力。
|
||||
- 可能涉及的信息:蓝牙设备信息、您主动选择的图片或文件、网络请求状态。
|
||||
- 使用场景:蓝牙设备、上传照片、上传 PDF、健康趋势、合规文本展示。
|
||||
|
||||
我们不会向第三方出售您的个人信息。涉及个人信息处理的第三方服务,会在实现相关功能所必需的范围内使用,并在正式上线前结合实际服务商完善披露信息。
|
||||
''',
|
||||
};
|
||||
|
||||
String staticTextTitle(String type) => _extraStaticTextTitles[type] ?? '';
|
||||
|
||||
String staticTextContent(String type) => _extraStaticTextContents[type] ?? '';
|
||||
|
||||
/// 静态文本页
|
||||
class StaticTextPage extends ConsumerWidget {
|
||||
final String type;
|
||||
const StaticTextPage({super.key, required this.type});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'};
|
||||
final titles = {
|
||||
'privacy': '隐私协议',
|
||||
'terms': '服务协议',
|
||||
'about': '关于小脉健康',
|
||||
..._extraStaticTextTitles,
|
||||
};
|
||||
final contents = {
|
||||
'privacy': '''## 小脉健康隐私政策
|
||||
|
||||
@@ -3676,6 +3773,7 @@ class StaticTextPage extends ConsumerWidget {
|
||||
### 版权声明
|
||||
© 2025-2026 小脉健康团队。保留所有权利。
|
||||
本软件受中华人民共和国著作权法保护。''',
|
||||
..._extraStaticTextContents,
|
||||
};
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
|
||||
@@ -7,11 +7,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_module_visuals.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/api_client.dart' show baseUrl;
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/common_widgets.dart';
|
||||
import '../../widgets/enterprise_widgets.dart';
|
||||
|
||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
||||
@@ -447,8 +449,26 @@ class ReportListPage extends ConsumerWidget {
|
||||
if (state.reports.isEmpty)
|
||||
_buildEmptyState(context)
|
||||
else
|
||||
...state.reports.map(
|
||||
(report) => _buildReportCard(context, ref, report),
|
||||
_ReportListGroup(
|
||||
children: [
|
||||
for (var i = 0; i < state.reports.length; i++)
|
||||
SwipeDeleteTile(
|
||||
key: Key(state.reports[i].id),
|
||||
onDelete: () => ref
|
||||
.read(reportProvider.notifier)
|
||||
.deleteReport(state.reports[i].id),
|
||||
onTap: () => pushRoute(
|
||||
ref,
|
||||
'aiAnalysis',
|
||||
params: {'id': state.reports[i].id},
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
child: _buildReportRow(
|
||||
state.reports[i],
|
||||
showDivider: i < state.reports.length - 1,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -461,7 +481,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: AppRadius.mdBorder,
|
||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
|
||||
),
|
||||
child: Row(
|
||||
@@ -576,7 +596,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
height: 100,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(50),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
),
|
||||
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
||||
),
|
||||
@@ -599,45 +619,33 @@ 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(
|
||||
return Material(
|
||||
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: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: _reportBlue.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
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),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -646,19 +654,14 @@ class ReportListPage extends ConsumerWidget {
|
||||
displayTitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
style: AppTextStyles.listTitle.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
_formatDate(report.uploadedAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
style: AppTextStyles.listSubtitle,
|
||||
),
|
||||
if (report.aiStatus == 'Failed') ...[
|
||||
const SizedBox(height: 4),
|
||||
@@ -677,45 +680,29 @@ class ReportListPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_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),
|
||||
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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -748,7 +735,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
borderRadius: AppRadius.pillBorder,
|
||||
border: Border.all(color: fg.withValues(alpha: 0.12)),
|
||||
),
|
||||
child: Text(
|
||||
@@ -776,6 +763,24 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ReportListGroup extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
|
||||
const _ReportListGroup({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
clipBehavior: Clip.antiAlias,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: AppRadius.xlBorder,
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String _catTitle(String c) => switch (c) {
|
||||
'BloodTest' => '抽血化验单',
|
||||
'Biochemistry' => '生化检验报告',
|
||||
@@ -786,20 +791,20 @@ String _catTitle(String c) => switch (c) {
|
||||
_ => '检查报告',
|
||||
};
|
||||
|
||||
class ReportOriginalPage extends StatelessWidget {
|
||||
class ReportOriginalPage extends ConsumerWidget {
|
||||
final String url;
|
||||
final String title;
|
||||
|
||||
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final imageUrl = _absoluteUrl(url);
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: Text(title),
|
||||
),
|
||||
|
||||
@@ -144,19 +144,26 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── 推送总开关 ──
|
||||
_SectionTitle(title: '推送通知'),
|
||||
_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: '通知类型'),
|
||||
_SettingsListSection(
|
||||
title: '通知类型',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
icon: Icons.medication_rounded,
|
||||
iconBg: AppColors.errorLight,
|
||||
@@ -197,14 +204,19 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: 'AI 回复通知',
|
||||
subtitle: 'AI 助手回复时发送通知',
|
||||
value: prefs['aiReply'] ?? false,
|
||||
showDivider: false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('aiReply'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 健康录入提醒 ──
|
||||
_SectionTitle(title: '健康录入提醒'),
|
||||
_SettingsListSection(
|
||||
title: '健康录入提醒',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
icon: Icons.alarm_on_rounded,
|
||||
iconBg: const Color(0xFFFEF3C7),
|
||||
@@ -212,6 +224,7 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
title: '每日录入提醒',
|
||||
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||
value: prefs['healthRecord'] ?? true,
|
||||
showDivider: prefs['healthRecord'] ?? true,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord'),
|
||||
@@ -248,36 +261,37 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
_SwitchTile(
|
||||
title: ' · 体重',
|
||||
value: prefs['healthRecord.weight'] ?? true,
|
||||
showDivider: false,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('healthRecord.weight'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// ── 免打扰时段 ──
|
||||
_SectionTitle(title: '免打扰时段'),
|
||||
_SettingsListSection(
|
||||
title: '免打扰时段',
|
||||
children: [
|
||||
_SwitchTile(
|
||||
title: '开启免打扰模式',
|
||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||
value: dndOn,
|
||||
showDivider: dndOn,
|
||||
onChanged: (v) => ref
|
||||
.read(notificationPrefsProvider.notifier)
|
||||
.toggle('dndEnabled'),
|
||||
),
|
||||
if (dndOn) ...[
|
||||
if (dndOn)
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 14,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
decoration: BoxDecoration(color: AppTheme.surface),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
@@ -287,7 +301,10 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
||||
initialTime: const TimeOfDay(
|
||||
hour: 22,
|
||||
minute: 0,
|
||||
),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
@@ -314,7 +331,10 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
onTap: () async {
|
||||
final picked = await showAppTimePicker(
|
||||
context,
|
||||
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
||||
initialTime: const TimeOfDay(
|
||||
hour: 8,
|
||||
minute: 0,
|
||||
),
|
||||
);
|
||||
if (picked != null && context.mounted) {
|
||||
ref
|
||||
@@ -327,8 +347,8 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
@@ -358,6 +378,30 @@ class _SectionTitle extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsListSection extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _SettingsListSection({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_SectionTitle(title: title),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
child: ColoredBox(
|
||||
color: AppTheme.surface,
|
||||
child: Column(children: children),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SwitchTile extends StatelessWidget {
|
||||
final IconData? icon;
|
||||
final Color? iconBg;
|
||||
@@ -365,6 +409,7 @@ class _SwitchTile extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
final bool value;
|
||||
final bool showDivider;
|
||||
final ValueChanged<bool> onChanged;
|
||||
|
||||
const _SwitchTile({
|
||||
@@ -374,33 +419,38 @@ class _SwitchTile extends StatelessWidget {
|
||||
required this.title,
|
||||
this.subtitle,
|
||||
required this.value,
|
||||
this.showDivider = true,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
margin: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
border: showDivider
|
||||
? Border(
|
||||
bottom: BorderSide(
|
||||
color: AppColors.borderLight.withValues(alpha: 0.75),
|
||||
),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: iconBg ?? AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||
),
|
||||
child: Icon(
|
||||
icon,
|
||||
size: 23,
|
||||
size: 20,
|
||||
color: iconColor ?? AppColors.primary,
|
||||
),
|
||||
),
|
||||
@@ -413,15 +463,17 @@ class _SwitchTile extends StatelessWidget {
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 16,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w500,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
const SizedBox(height: 2),
|
||||
if (subtitle != null && subtitle!.isNotEmpty)
|
||||
Text(
|
||||
subtitle!,
|
||||
style: TextStyle(fontSize: 15, color: AppTheme.textSub),
|
||||
style: TextStyle(fontSize: 13, color: AppTheme.textSub),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_design_tokens.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
@@ -37,39 +38,60 @@ class SettingsPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
_SettingsGroup(
|
||||
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'}),
|
||||
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'},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: Icons.description_outlined,
|
||||
title: '服务协议',
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _deleteAccount(context, ref),
|
||||
@@ -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),
|
||||
|
||||
@@ -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}',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,6 +22,7 @@ class DrawerShell extends StatelessWidget {
|
||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBackground) ...[
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/branding/drawer_background_v1.png',
|
||||
@@ -39,6 +46,8 @@ class DrawerShell extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
] else
|
||||
const Positioned.fill(child: ColoredBox(color: Colors.white)),
|
||||
child,
|
||||
],
|
||||
),
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,27 +21,96 @@ class HealthDrawer extends ConsumerWidget {
|
||||
|
||||
return DrawerShell(
|
||||
widthFactor: 0.9,
|
||||
child: SafeArea(
|
||||
showBackground: false,
|
||||
child: CustomScrollView(
|
||||
physics: const BouncingScrollPhysics(),
|
||||
slivers: [
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
|
||||
sliver: SliverList.list(
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
child: Icon(item.icon, color: Colors.white, size: 26),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
_title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
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,31 +781,43 @@ class _HistorySection extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
}
|
||||
final preview = list.take(_previewCount).toList();
|
||||
return Column(
|
||||
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 {
|
||||
Navigator.of(context).maybePop(); // 关闭侧边栏
|
||||
await ref
|
||||
if (_selecting) {
|
||||
_toggleSelect(preview[i].id);
|
||||
return;
|
||||
}
|
||||
final error = 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 (!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 > _previewCount) ...[
|
||||
if (list.length > _HistorySection._previewCount) ...[
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
@@ -717,7 +828,10 @@ class _HistorySection extends ConsumerWidget {
|
||||
},
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
'查看全部',
|
||||
style: TextStyle(
|
||||
@@ -731,129 +845,168 @@ class _HistorySection extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
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(
|
||||
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,
|
||||
),
|
||||
child: 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,
|
||||
_HistoryActionButton(
|
||||
icon: Icons.delete_outline_rounded,
|
||||
label: deleting ? '删除中' : '删除',
|
||||
color: AppColors.error,
|
||||
onTap: deleting ? null : onDelete,
|
||||
),
|
||||
),
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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('取消'),
|
||||
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,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(ctx, true),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
child: const Text('清空'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
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(
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
onLongPress: onLongPress,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
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)),
|
||||
),
|
||||
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(
|
||||
@@ -861,10 +1014,12 @@ class _DrawerHistoryTile extends StatelessWidget {
|
||||
_displaySummary(item),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
color: selected
|
||||
? AppColors.textSecondary
|
||||
: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
@@ -881,6 +1036,14 @@ class _DrawerHistoryTile extends StatelessWidget {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (!isLast)
|
||||
const Divider(
|
||||
height: 1,
|
||||
thickness: 0.7,
|
||||
color: Color(0xFFE8ECF2),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
59
health_app/test/chat_provider_test.dart
Normal file
59
health_app/test/chat_provider_test.dart
Normal 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);
|
||||
},
|
||||
);
|
||||
}
|
||||
37
health_app/test/prelaunch_guardrails_test.dart
Normal file
37
health_app/test/prelaunch_guardrails_test.dart
Normal 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,
|
||||
);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user