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;
|
namespace Health.Application.Exercises;
|
||||||
|
|
||||||
public sealed record ExercisePlanItemInput(
|
|
||||||
DateOnly ScheduledDate,
|
|
||||||
string? ExerciseType,
|
|
||||||
int DurationMinutes,
|
|
||||||
bool IsRestDay);
|
|
||||||
|
|
||||||
public sealed record ExercisePlanCreateRequest(
|
public sealed record ExercisePlanCreateRequest(
|
||||||
DateOnly StartDate,
|
DateOnly StartDate,
|
||||||
DateOnly EndDate,
|
DateOnly EndDate,
|
||||||
@@ -47,7 +41,6 @@ public interface IExerciseService
|
|||||||
{
|
{
|
||||||
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetCurrentAsync(Guid userId, CancellationToken ct);
|
||||||
Task<Guid> CreateAsync(Guid userId, ExercisePlanCreateRequest request, 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<IReadOnlyList<ExercisePlanSummaryDto>> ListAsync(Guid userId, CancellationToken ct);
|
||||||
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
Task<ExercisePlanDto?> GetByIdAsync(Guid userId, Guid planId, CancellationToken ct);
|
||||||
Task<bool> DeleteAsync(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;
|
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,这里只拦截不合理的超大值
|
// 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值
|
||||||
private static void ValidateDuration(int durationMinutes)
|
private static void ValidateDuration(int durationMinutes)
|
||||||
|
|||||||
@@ -37,7 +37,6 @@ public sealed record HealthArchiveUpdateRequest(
|
|||||||
public interface IHealthArchiveService
|
public interface IHealthArchiveService
|
||||||
{
|
{
|
||||||
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
Task<HealthArchiveDto?> GetAsync(Guid userId, CancellationToken ct);
|
||||||
Task<HealthArchiveDto> GetOrCreateAsync(Guid userId, CancellationToken ct);
|
|
||||||
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct);
|
||||||
Task<object> ExecuteAiActionAsync(Guid userId, string action, 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);
|
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)
|
public async Task<HealthArchiveDto> UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var archive = await GetOrCreateEntityAsync(userId, 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 List<ToolDefinition> Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool];
|
||||||
|
|
||||||
public static async Task<object> Execute(
|
public static async Task<object> Execute(
|
||||||
string toolName,
|
string toolName,
|
||||||
JsonElement args,
|
JsonElement args,
|
||||||
AppDbContext db,
|
|
||||||
Guid userId,
|
Guid userId,
|
||||||
IMedicationService? medications = null,
|
IMedicationService medications,
|
||||||
CancellationToken ct = default)
|
CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
return toolName switch
|
return toolName switch
|
||||||
{
|
{
|
||||||
"manage_medication" => medications != null
|
"manage_medication" => await ExecuteManageMedication(medications, userId, args, ct),
|
||||||
? await ExecuteManageMedication(medications, userId, args, ct)
|
_ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" }
|
||||||
: await ExecuteManageMedication(db, userId, args),
|
|
||||||
_ => new { success = false, message = $"未知工具: {toolName}" }
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,82 +107,6 @@ public static class MedicationAgentHandler
|
|||||||
return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" };
|
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)
|
private static MedicationFrequency ReadFrequency(JsonElement args)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ public sealed class AiToolExecutionService(
|
|||||||
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
"record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords),
|
||||||
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
"query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct),
|
||||||
"check_archive" => 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),
|
"analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient),
|
||||||
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct),
|
||||||
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, 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>()
|
new DbContextOptionsBuilder<AppDbContext>()
|
||||||
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
.UseInMemoryDatabase(Guid.NewGuid().ToString())
|
||||||
.Options);
|
.Options);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.212.224:5000',
|
defaultValue: 'http://10.4.221.78:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -4,12 +4,30 @@ import 'package:fl_chart/fl_chart.dart';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../widgets/app_toast.dart';
|
import '../../widgets/app_toast.dart';
|
||||||
import '../../providers/data_providers.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 {
|
class TrendPage extends ConsumerStatefulWidget {
|
||||||
final String? metricType; // 可选初始选中指标
|
final String? metricType; // 可选初始选中指标
|
||||||
@@ -20,7 +38,7 @@ class TrendPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _TrendPageState extends ConsumerState<TrendPage> {
|
class _TrendPageState extends ConsumerState<TrendPage> {
|
||||||
String _selected = 'blood_pressure';
|
String _selected = defaultTrendMetricType;
|
||||||
List<Map<String, dynamic>> _allRecords = [];
|
List<Map<String, dynamic>> _allRecords = [];
|
||||||
List<Map<String, dynamic>> _filtered = [];
|
List<Map<String, dynamic>> _filtered = [];
|
||||||
bool _loading = true;
|
bool _loading = true;
|
||||||
@@ -84,12 +102,15 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
String get _name => _names[_selected] ?? '';
|
String get _name => _names[_selected] ?? '';
|
||||||
bool get _isBP => _selected == 'blood_pressure';
|
bool get _isBP => _selected == 'blood_pressure';
|
||||||
Color get _color =>
|
Color get _color =>
|
||||||
_metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color;
|
_metrics.firstWhere(
|
||||||
|
(m) => m['key'] == normalizeTrendMetricType(_selected),
|
||||||
|
)['color']
|
||||||
|
as Color;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
if (widget.metricType != null) _selected = widget.metricType!;
|
_selected = normalizeTrendMetricType(widget.metricType);
|
||||||
_loadAll();
|
_loadAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,7 +314,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
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('健康概览'),
|
title: const Text('健康概览'),
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
),
|
),
|
||||||
@@ -431,8 +455,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// X 轴刻度间隔:日标签短,可以排密一点
|
// X 轴刻度间隔:日标签短,可以排密一点
|
||||||
final xInterval =
|
final xInterval = spots.length > 60
|
||||||
spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0;
|
? 5.0
|
||||||
|
: spots.length > 30
|
||||||
|
? 2.0
|
||||||
|
: 1.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
padding: const EdgeInsets.fromLTRB(8, 20, 16, 12),
|
||||||
@@ -490,10 +517,10 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
? (r['systolic'] as num?)?.toDouble()
|
? (r['systolic'] as num?)?.toDouble()
|
||||||
: (r['value'] as num?)?.toDouble();
|
: (r['value'] as num?)?.toDouble();
|
||||||
if (v != null && spots.length > 1) {
|
if (v != null && spots.length > 1) {
|
||||||
final px = 40.0 +
|
final px =
|
||||||
|
40.0 +
|
||||||
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
(_selectedIdx! / (spots.length - 1)) * paintWidth;
|
||||||
final py =
|
final py = paintHeight * (1 - (v - minV) / (maxV - minV));
|
||||||
paintHeight * (1 - (v - minV) / (maxV - minV));
|
|
||||||
final d = r['date'] as DateTime;
|
final d = r['date'] as DateTime;
|
||||||
tooltipPos = Offset(px, py);
|
tooltipPos = Offset(px, py);
|
||||||
tooltipText1 =
|
tooltipText1 =
|
||||||
@@ -546,9 +573,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
final d = _filtered[idx]['date'] as DateTime;
|
final d = _filtered[idx]['date'] as DateTime;
|
||||||
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
// 当前可见刻度与上一个可见刻度相比,月份是否变化
|
||||||
final prevIdx = idx - xInterval.toInt();
|
final prevIdx = idx - xInterval.toInt();
|
||||||
final showMonth = idx == 0 ||
|
final showMonth =
|
||||||
|
idx == 0 ||
|
||||||
prevIdx < 0 ||
|
prevIdx < 0 ||
|
||||||
(_filtered[prevIdx]['date'] as DateTime).month !=
|
(_filtered[prevIdx]['date'] as DateTime)
|
||||||
|
.month !=
|
||||||
d.month;
|
d.month;
|
||||||
return Column(
|
return Column(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -594,7 +623,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
dotData: FlDotData(
|
dotData: FlDotData(
|
||||||
show: spots.length <= 30,
|
show: spots.length <= 30,
|
||||||
getDotPainter: (spot, _, _, _) {
|
getDotPainter: (spot, _, _, _) {
|
||||||
final isSelected = spot.x.toInt() == _selectedIdx;
|
final isSelected =
|
||||||
|
spot.x.toInt() == _selectedIdx;
|
||||||
if (isSelected) {
|
if (isSelected) {
|
||||||
return FlDotCirclePainter(
|
return FlDotCirclePainter(
|
||||||
radius: 5.5,
|
radius: 5.5,
|
||||||
@@ -636,8 +666,11 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
final spots = response?.lineBarSpots;
|
final spots = response?.lineBarSpots;
|
||||||
if (spots != null && spots.isNotEmpty) {
|
if (spots != null && spots.isNotEmpty) {
|
||||||
final idx = spots.first.x.toInt();
|
final idx = spots.first.x.toInt();
|
||||||
setState(() =>
|
setState(
|
||||||
_selectedIdx = (_selectedIdx == idx) ? null : idx);
|
() => _selectedIdx = (_selectedIdx == idx)
|
||||||
|
? null
|
||||||
|
: idx,
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
setState(() => _selectedIdx = null);
|
setState(() => _selectedIdx = null);
|
||||||
}
|
}
|
||||||
@@ -666,8 +699,9 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
),
|
),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF101828)
|
color: const Color(
|
||||||
.withValues(alpha: 0.14),
|
0xFF101828,
|
||||||
|
).withValues(alpha: 0.14),
|
||||||
blurRadius: 16,
|
blurRadius: 16,
|
||||||
offset: const Offset(0, 6),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
@@ -731,7 +765,20 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
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 date = r['date'] as DateTime;
|
||||||
final abnormal = r['isAbnormal'] == true;
|
final abnormal = r['isAbnormal'] == true;
|
||||||
final id = r['id']?.toString() ?? '';
|
final id = r['id']?.toString() ?? '';
|
||||||
@@ -739,7 +786,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
final isSelected = idx == _selectedIdx;
|
final isSelected = idx == _selectedIdx;
|
||||||
String display;
|
String display;
|
||||||
if (_isBP) {
|
if (_isBP) {
|
||||||
display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
display =
|
||||||
|
'${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}';
|
||||||
} else {
|
} else {
|
||||||
display = '${r['value'] ?? '--'}';
|
display = '${r['value'] ?? '--'}';
|
||||||
}
|
}
|
||||||
@@ -747,21 +795,22 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
key: Key(id),
|
key: Key(id),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.error,
|
color: AppColors.error,
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 20),
|
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 {
|
confirmDismiss: (_) async {
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
await api.delete('/api/health-records/$id');
|
await api.delete('/api/health-records/$id');
|
||||||
setState(() {
|
setState(() {
|
||||||
_allRecords.removeWhere((x) => x['id']?.toString() == id);
|
_allRecords.removeWhere(
|
||||||
|
(x) => x['id']?.toString() == id,
|
||||||
|
);
|
||||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||||
_selectedIdx = null;
|
_selectedIdx = null;
|
||||||
});
|
});
|
||||||
@@ -773,26 +822,19 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (idx < 0) return;
|
if (idx < 0) return;
|
||||||
setState(() =>
|
setState(() => _selectedIdx = isSelected ? null : idx);
|
||||||
_selectedIdx = isSelected ? null : idx);
|
|
||||||
},
|
},
|
||||||
child: AnimatedContainer(
|
child: AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 180),
|
duration: const Duration(milliseconds: 180),
|
||||||
margin: const EdgeInsets.only(bottom: 6),
|
|
||||||
padding:
|
|
||||||
const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: isSelected
|
color: isSelected
|
||||||
? _color.withValues(alpha: 0.15)
|
? _color.withValues(alpha: 0.10)
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
child: Column(
|
||||||
border: Border.all(
|
children: [
|
||||||
color: isSelected
|
Padding(
|
||||||
? _color.withValues(alpha: 0.55)
|
padding: const EdgeInsets.symmetric(
|
||||||
: AppColors.border,
|
horizontal: 14,
|
||||||
width: isSelected ? 1.5 : 1,
|
vertical: 12,
|
||||||
),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -801,7 +843,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _color.withAlpha(20),
|
color: _color.withAlpha(20),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
child: Icon(
|
child: Icon(
|
||||||
@@ -814,7 +856,8 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
|
CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Text(
|
||||||
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
'${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}',
|
||||||
@@ -845,7 +888,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.errorLight,
|
color: AppColors.errorLight,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: AppRadius.xsBorder,
|
||||||
),
|
),
|
||||||
child: const Text(
|
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),
|
name: _deviceNameOf(result),
|
||||||
)
|
)
|
||||||
.timeout(const Duration(seconds: 15));
|
.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);
|
await ref.read(omronDeviceProvider.notifier).bind(boundDevice);
|
||||||
|
|
||||||
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
if (boundDevice.type == BleDeviceType.bloodPressure) {
|
||||||
@@ -223,8 +234,6 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage>
|
|||||||
await _showBloodPressureDialog(syncResult.reading);
|
await _showBloodPressureDialog(syncResult.reading);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (mounted) {
|
|
||||||
popRoute(ref);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (mounted) popRoute(ref);
|
if (mounted) popRoute(ref);
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class ConversationHistoryPage extends ConsumerWidget {
|
|||||||
final item = list[index];
|
final item = list[index];
|
||||||
return _HistoryTile(
|
return _HistoryTile(
|
||||||
item: item,
|
item: item,
|
||||||
onTap: () => _openConversation(ref, item.id),
|
onTap: () => _openConversation(context, ref, item.id),
|
||||||
onDelete: () => _deleteOne(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 {
|
Future<void> _openConversation(
|
||||||
await ref.read(chatProvider.notifier).loadConversation(id);
|
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);
|
popRoute(ref);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
final imagePath = _pickedImagePath;
|
final imagePath = _pickedImagePath;
|
||||||
if (text.isEmpty && imagePath == null) return;
|
if (text.isEmpty && imagePath == null) return;
|
||||||
_textCtrl.clear();
|
_textCtrl.clear();
|
||||||
_focusNode.unfocus();
|
_dismissKeyboard();
|
||||||
setState(() => _pickedImagePath = null);
|
setState(() => _pickedImagePath = null);
|
||||||
if (imagePath != null) {
|
if (imagePath != null) {
|
||||||
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||||
@@ -145,9 +145,9 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
left: 0,
|
left: 0,
|
||||||
top: 0,
|
top: 0,
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
width: 44,
|
width: MediaQuery.sizeOf(context).width * 0.8,
|
||||||
child: GestureDetector(
|
child: GestureDetector(
|
||||||
behavior: HitTestBehavior.opaque,
|
behavior: HitTestBehavior.translucent,
|
||||||
onHorizontalDragStart: (details) {
|
onHorizontalDragStart: (details) {
|
||||||
_drawerDragStartX = details.globalPosition.dx;
|
_drawerDragStartX = details.globalPosition.dx;
|
||||||
},
|
},
|
||||||
@@ -255,16 +255,20 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
];
|
];
|
||||||
|
|
||||||
Widget _buildAgentBar() {
|
Widget _buildAgentBar() {
|
||||||
|
final activeAgent = ref.watch(
|
||||||
|
chatProvider.select((state) => state.activeAgent),
|
||||||
|
);
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: 42,
|
height: 46,
|
||||||
child: ListView.separated(
|
child: ListView.separated(
|
||||||
scrollDirection: Axis.horizontal,
|
scrollDirection: Axis.horizontal,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2),
|
||||||
itemCount: _agentDefs.length,
|
itemCount: _agentDefs.length,
|
||||||
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
separatorBuilder: (_, _) => const SizedBox(width: 8),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
final agent = _agentDefs[i];
|
final agent = _agentDefs[i];
|
||||||
final visual = _agentVisual(agent);
|
final visual = _agentVisual(agent);
|
||||||
|
final selected = activeAgent == agent;
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => ref
|
onTap: () => ref
|
||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
@@ -272,9 +276,14 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.86),
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(999),
|
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(
|
child: Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@@ -477,6 +486,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _showAttachmentPicker(BuildContext context) {
|
void _showAttachmentPicker(BuildContext context) {
|
||||||
|
_dismissKeyboard();
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(
|
||||||
context: context,
|
context: context,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
@@ -496,6 +506,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
title: const Text('拍照'),
|
title: const Text('拍照'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
|
_dismissKeyboard();
|
||||||
_pickImage(ImageSource.camera);
|
_pickImage(ImageSource.camera);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -507,6 +518,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
title: const Text('从相册选择'),
|
title: const Text('从相册选择'),
|
||||||
onTap: () {
|
onTap: () {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
|
_dismissKeyboard();
|
||||||
_pickImage(ImageSource.gallery);
|
_pickImage(ImageSource.gallery);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -518,6 +530,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
title: const Text('上传 PDF'),
|
title: const Text('上传 PDF'),
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.pop(ctx);
|
Navigator.pop(ctx);
|
||||||
|
_dismissKeyboard();
|
||||||
final result = await FilePicker.platform.pickFiles(
|
final result = await FilePicker.platform.pickFiles(
|
||||||
type: FileType.custom,
|
type: FileType.custom,
|
||||||
allowedExtensions: ['pdf'],
|
allowedExtensions: ['pdf'],
|
||||||
@@ -532,6 +545,7 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
.sendPdf(path, pdfFile.name, _textCtrl.text.trim());
|
||||||
_textCtrl.clear();
|
_textCtrl.clear();
|
||||||
|
_dismissKeyboard();
|
||||||
if (mounted) setState(() {});
|
if (mounted) setState(() {});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
@@ -543,17 +557,25 @@ class _HomePageState extends ConsumerState<HomePage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source) async {
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
|
_dismissKeyboard();
|
||||||
final picked = await ImagePicker().pickImage(
|
final picked = await ImagePicker().pickImage(
|
||||||
source: source,
|
source: source,
|
||||||
imageQuality: 85,
|
imageQuality: 85,
|
||||||
);
|
);
|
||||||
|
_dismissKeyboard();
|
||||||
if (picked != null) {
|
if (picked != null) {
|
||||||
final token = await ref.read(apiClientProvider).accessToken;
|
final token = await ref.read(apiClientProvider).accessToken;
|
||||||
if (token == null) return;
|
if (token == null) return;
|
||||||
setState(() => _pickedImagePath = picked.path);
|
setState(() => _pickedImagePath = picked.path);
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _dismissKeyboard() {
|
||||||
|
_focusNode.unfocus();
|
||||||
|
FocusManager.instance.primaryFocus?.unfocus();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _pickFoodImage(ImageSource source) async {
|
Future<void> _pickFoodImage(ImageSource source) async {
|
||||||
final picked = await ImagePicker().pickImage(
|
final picked = await ImagePicker().pickImage(
|
||||||
source: source,
|
source: source,
|
||||||
|
|||||||
@@ -859,13 +859,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: msg.confirmed
|
child: msg.confirmed || msg.isReadOnly
|
||||||
? Container(
|
? Container(
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.successButtonGradient,
|
gradient: AppColors.successButtonGradient,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: const Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(
|
||||||
@@ -873,10 +873,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
size: 22,
|
size: 22,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'录入成功',
|
msg.isReadOnly ? '历史记录' : '录入成功',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
@@ -1236,9 +1236,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: InteractiveViewer(
|
child: InteractiveViewer(
|
||||||
child: path.startsWith('http')
|
child: resolvedPath.startsWith('http')
|
||||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
|
||||||
: resolvedPath.startsWith('http')
|
|
||||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||||
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||||
),
|
),
|
||||||
@@ -1270,20 +1268,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return path;
|
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 链接点击:
|
/// 处理 AI 回复里的 markdown 链接点击:
|
||||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||||
@@ -2098,77 +2082,3 @@ extension _AgentActionsExt on ActiveAgent {
|
|||||||
_agentActions[this] ??
|
_agentActions[this] ??
|
||||||
[const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)];
|
[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),
|
const SizedBox(height: 10),
|
||||||
...List.generate(list.length, (i) {
|
_MedicationListGroup(
|
||||||
|
children: List.generate(list.length, (i) {
|
||||||
final m = list[i];
|
final m = list[i];
|
||||||
final times =
|
final times =
|
||||||
(m['timeOfDay'] as List?)
|
(m['timeOfDay'] as List?)
|
||||||
@@ -114,15 +115,70 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
return SwipeDeleteTile(
|
return SwipeDeleteTile(
|
||||||
key: Key(id),
|
key: Key(id),
|
||||||
onDelete: () => _delete(id),
|
onDelete: () => _delete(id),
|
||||||
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
onTap: () =>
|
||||||
margin: const EdgeInsets.symmetric(vertical: 5),
|
pushRoute(ref, 'medCheckIn', params: {'id': id}),
|
||||||
child: Container(
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
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(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
),
|
||||||
boxShadow: [AppTheme.shadowLight],
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -131,13 +187,13 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
height: 44,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: isActive
|
gradient: isActive
|
||||||
? _medVisual.gradient
|
? visual.gradient
|
||||||
: AppColors.surfaceGradient,
|
: AppColors.surfaceGradient,
|
||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
_medVisual.icon,
|
visual.icon,
|
||||||
size: 25,
|
size: 25,
|
||||||
color: isActive ? Colors.white : AppTheme.textSub,
|
color: isActive ? Colors.white : AppTheme.textSub,
|
||||||
),
|
),
|
||||||
@@ -149,26 +205,29 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Text(
|
Flexible(
|
||||||
m['name']?.toString() ?? '',
|
child: Text(
|
||||||
|
name,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTextStyles.listTitle.copyWith(
|
style: AppTextStyles.listTitle.copyWith(
|
||||||
color: isActive
|
color: isActive
|
||||||
? AppTheme.text
|
? AppTheme.text
|
||||||
: AppTheme.textSub,
|
: AppTheme.textSub,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
),
|
||||||
if (!isActive) ...[
|
if (!isActive) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
AppStatusBadge(
|
AppStatusBadge(label: '已停', color: visual.color),
|
||||||
label: '已停',
|
|
||||||
color: _medVisual.color,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 2),
|
const SizedBox(height: 2),
|
||||||
Text(
|
Text(
|
||||||
'${m['dosage'] ?? ''} $times',
|
subtitle,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: AppTextStyles.listSubtitle.copyWith(
|
style: AppTextStyles.listSubtitle.copyWith(
|
||||||
color: AppTheme.textSub,
|
color: AppTheme.textSub,
|
||||||
),
|
),
|
||||||
@@ -176,7 +235,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(
|
const Icon(
|
||||||
Icons.chevron_right,
|
Icons.chevron_right,
|
||||||
size: 21,
|
size: 21,
|
||||||
color: AppColors.textHint,
|
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 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_module_visuals.dart';
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
@@ -240,7 +241,12 @@ class _NotificationCenterPageState
|
|||||||
if (today.isNotEmpty) ...[
|
if (today.isNotEmpty) ...[
|
||||||
const _SectionTitle('今天'),
|
const _SectionTitle('今天'),
|
||||||
const SizedBox(height: 10),
|
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 (earlier.isNotEmpty) ...[
|
||||||
if (today.isNotEmpty) const SizedBox(height: 18),
|
if (today.isNotEmpty) const SizedBox(height: 18),
|
||||||
@@ -251,24 +257,31 @@ class _NotificationCenterPageState
|
|||||||
onTap: () => setState(() => _showEarlier = !_showEarlier),
|
onTap: () => setState(() => _showEarlier = !_showEarlier),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
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(
|
Widget _buildDismissible(
|
||||||
padding: const EdgeInsets.only(bottom: 12),
|
InAppNotification item, {
|
||||||
child: Dismissible(
|
required bool showDivider,
|
||||||
|
}) => Dismissible(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 26),
|
padding: const EdgeInsets.only(right: 26),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.error,
|
color: AppColors.error,
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Row(
|
child: const Row(
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
@@ -286,14 +299,39 @@ class _NotificationCenterPageState
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onDismissed: (_) => _delete(item),
|
onDismissed: (_) => _delete(item),
|
||||||
child: SizedBox(
|
child: _NotificationRow(
|
||||||
height: 98,
|
item: item,
|
||||||
child: _NotificationCard(item: item, onTap: () => _open(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 {
|
class _UnreadHint extends StatelessWidget {
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
|
|
||||||
@@ -305,7 +343,7 @@ class _UnreadHint extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBEB),
|
color: const Color(0xFFFFFBEB),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -359,23 +397,8 @@ class _SectionShell extends StatelessWidget {
|
|||||||
const _SectionShell({required this.child});
|
const _SectionShell({required this.child});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Container(
|
Widget build(BuildContext context) =>
|
||||||
margin: const EdgeInsets.fromLTRB(0, 6, 0, 2),
|
Padding(padding: const EdgeInsets.fromLTRB(2, 12, 2, 8), child: child);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _CollapsibleSectionTitle extends StatelessWidget {
|
class _CollapsibleSectionTitle extends StatelessWidget {
|
||||||
@@ -433,38 +456,31 @@ class _CollapsibleSectionTitle extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _NotificationCard extends StatelessWidget {
|
class _NotificationRow extends StatelessWidget {
|
||||||
final InAppNotification item;
|
final InAppNotification item;
|
||||||
|
final bool showDivider;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
const _NotificationCard({required this.item, required this.onTap});
|
const _NotificationRow({
|
||||||
|
required this.item,
|
||||||
|
required this.showDivider,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final visual = _NotificationVisual.of(item);
|
final visual = _NotificationVisual.of(item);
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
height: 98,
|
constraints: const BoxConstraints(minHeight: 94),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
child: Column(
|
||||||
border: Border.all(
|
|
||||||
color: item.isRead
|
|
||||||
? AppColors.borderLight
|
|
||||||
: visual.color.withValues(alpha: 0.32),
|
|
||||||
width: 1.1,
|
|
||||||
),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
|
||||||
child: Stack(
|
|
||||||
children: [
|
children: [
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Stack(
|
||||||
@@ -482,7 +498,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
visual.lightColor,
|
visual.lightColor,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: visual.color.withValues(alpha: 0.22),
|
color: visual.color.withValues(alpha: 0.22),
|
||||||
),
|
),
|
||||||
@@ -527,7 +543,7 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: visual.lightColor,
|
color: visual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: AppRadius.pillBorder,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
visual.label,
|
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_markdown/flutter_markdown.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import '../core/app_design_tokens.dart';
|
||||||
import '../core/app_module_visuals.dart';
|
import '../core/app_module_visuals.dart';
|
||||||
import '../core/app_theme.dart';
|
import '../core/app_theme.dart';
|
||||||
import '../core/navigation_provider.dart';
|
import '../core/navigation_provider.dart';
|
||||||
@@ -721,11 +722,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ExercisePlan] 打卡失败: $e');
|
debugPrint('[ExercisePlan] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppToast.show(
|
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||||
context,
|
|
||||||
'只能打卡今天的运动任务',
|
|
||||||
type: AppToastType.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||||
@@ -1304,11 +1301,7 @@ class _ExercisePlanDetailPageState
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppToast.show(
|
AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error);
|
||||||
context,
|
|
||||||
'只能打卡今天的运动任务',
|
|
||||||
type: AppToastType.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busyItems.remove(itemId));
|
if (mounted) setState(() => _busyItems.remove(itemId));
|
||||||
@@ -2462,7 +2455,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.errorLight,
|
color: AppColors.errorLight,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.close,
|
Icons.close,
|
||||||
@@ -2506,12 +2499,10 @@ class _Section extends StatelessWidget {
|
|||||||
};
|
};
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext c) => Container(
|
Widget build(BuildContext c) => Container(
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.border, width: 1.1),
|
|
||||||
boxShadow: [AppTheme.shadowLight],
|
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -2519,26 +2510,26 @@ class _Section extends StatelessWidget {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 36,
|
width: 30,
|
||||||
height: 36,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _color.withValues(alpha: 0.10),
|
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),
|
const SizedBox(width: 10),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 17,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 12),
|
||||||
...fields,
|
...fields,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -2572,15 +2563,15 @@ class _F extends StatelessWidget {
|
|||||||
vertical: 12,
|
vertical: 12,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2610,7 +2601,7 @@ class _GenderF extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.all(4),
|
padding: const EdgeInsets.all(4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -2641,7 +2632,7 @@ class _GenderChip extends StatelessWidget {
|
|||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: selected ? const Color(0xFF2563EB) : Colors.transparent,
|
color: selected ? const Color(0xFF2563EB) : Colors.transparent,
|
||||||
borderRadius: BorderRadius.circular(9),
|
borderRadius: AppRadius.smBorder,
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
label,
|
label,
|
||||||
@@ -2685,15 +2676,15 @@ class _F2 extends StatelessWidget {
|
|||||||
vertical: 8,
|
vertical: 8,
|
||||||
),
|
),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
enabledBorder: OutlineInputBorder(
|
enabledBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: AppColors.borderLight),
|
borderSide: const BorderSide(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
borderSide: const BorderSide(color: Color(0xFF60A5FA)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -2723,7 +2714,7 @@ class _DateF extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -2774,7 +2765,7 @@ class _DateF2 extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
alignment: Alignment.centerLeft,
|
alignment: Alignment.centerLeft,
|
||||||
@@ -2824,7 +2815,7 @@ class _AddBtn extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: const Color(0xFFEFF6FF),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: AppRadius.smBorder,
|
||||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||||
),
|
),
|
||||||
child: const Row(
|
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 {
|
class StaticTextPage extends ConsumerWidget {
|
||||||
final String type;
|
final String type;
|
||||||
const StaticTextPage({super.key, required this.type});
|
const StaticTextPage({super.key, required this.type});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'};
|
final titles = {
|
||||||
|
'privacy': '隐私协议',
|
||||||
|
'terms': '服务协议',
|
||||||
|
'about': '关于小脉健康',
|
||||||
|
..._extraStaticTextTitles,
|
||||||
|
};
|
||||||
final contents = {
|
final contents = {
|
||||||
'privacy': '''## 小脉健康隐私政策
|
'privacy': '''## 小脉健康隐私政策
|
||||||
|
|
||||||
@@ -3676,6 +3773,7 @@ class StaticTextPage extends ConsumerWidget {
|
|||||||
### 版权声明
|
### 版权声明
|
||||||
© 2025-2026 小脉健康团队。保留所有权利。
|
© 2025-2026 小脉健康团队。保留所有权利。
|
||||||
本软件受中华人民共和国著作权法保护。''',
|
本软件受中华人民共和国著作权法保护。''',
|
||||||
|
..._extraStaticTextContents,
|
||||||
};
|
};
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
|
|||||||
@@ -7,11 +7,13 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_module_visuals.dart';
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/api_client.dart' show baseUrl;
|
import '../../core/api_client.dart' show baseUrl;
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
import '../../widgets/common_widgets.dart';
|
||||||
import '../../widgets/enterprise_widgets.dart';
|
import '../../widgets/enterprise_widgets.dart';
|
||||||
|
|
||||||
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(
|
||||||
@@ -447,8 +449,26 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
if (state.reports.isEmpty)
|
if (state.reports.isEmpty)
|
||||||
_buildEmptyState(context)
|
_buildEmptyState(context)
|
||||||
else
|
else
|
||||||
...state.reports.map(
|
_ReportListGroup(
|
||||||
(report) => _buildReportCard(context, ref, report),
|
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),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.error.withValues(alpha: 0.08),
|
color: AppColors.error.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
|
border: Border.all(color: AppColors.error.withValues(alpha: 0.22)),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -576,7 +596,7 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
height: 100,
|
height: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _reportBlue.withValues(alpha: 0.08),
|
color: _reportBlue.withValues(alpha: 0.08),
|
||||||
borderRadius: BorderRadius.circular(50),
|
borderRadius: AppRadius.pillBorder,
|
||||||
),
|
),
|
||||||
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
child: Icon(_reportVisual.icon, size: 44, color: _reportBlue),
|
||||||
),
|
),
|
||||||
@@ -599,45 +619,33 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildReportCard(
|
Widget _buildReportRow(ReportItem report, {required bool showDivider}) {
|
||||||
BuildContext context,
|
|
||||||
WidgetRef ref,
|
|
||||||
ReportItem report,
|
|
||||||
) {
|
|
||||||
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
final displayTitle = (report.title == 'Other' || report.title == 'other')
|
||||||
? '检查报告'
|
? '检查报告'
|
||||||
: report.title;
|
: report.title;
|
||||||
return Container(
|
return Material(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white,
|
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(
|
child: InkWell(
|
||||||
onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}),
|
child: Column(
|
||||||
borderRadius: BorderRadius.circular(16),
|
children: [
|
||||||
child: Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(15),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 48,
|
width: 44,
|
||||||
height: 48,
|
height: 44,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: _reportBlue.withValues(alpha: 0.10),
|
color: _reportBlue.withValues(alpha: 0.10),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(
|
),
|
||||||
color: _reportBlue.withValues(alpha: 0.10),
|
child: Icon(
|
||||||
|
_reportVisual.icon,
|
||||||
|
size: 24,
|
||||||
|
color: _reportBlue,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Icon(_reportVisual.icon, size: 26, color: _reportBlue),
|
const SizedBox(width: 13),
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@@ -646,19 +654,14 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
displayTitle,
|
displayTitle,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: AppTextStyles.listTitle.copyWith(
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 3),
|
||||||
Text(
|
Text(
|
||||||
_formatDate(report.uploadedAt),
|
_formatDate(report.uploadedAt),
|
||||||
style: const TextStyle(
|
style: AppTextStyles.listSubtitle,
|
||||||
fontSize: 14,
|
|
||||||
color: AppColors.textHint,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (report.aiStatus == 'Failed') ...[
|
if (report.aiStatus == 'Failed') ...[
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@@ -677,45 +680,29 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
_buildStatusBadge(report),
|
_buildStatusBadge(report),
|
||||||
const SizedBox(width: 4),
|
const SizedBox(width: 8),
|
||||||
IconButton(
|
const Icon(
|
||||||
onPressed: () => _confirmDelete(context, ref, report.id),
|
Icons.chevron_right,
|
||||||
visualDensity: VisualDensity.compact,
|
size: 21,
|
||||||
icon: const Icon(
|
|
||||||
Icons.delete_outline,
|
|
||||||
size: 20,
|
|
||||||
color: AppColors.textHint,
|
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),
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: bg,
|
color: bg,
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: AppRadius.pillBorder,
|
||||||
border: Border.all(color: fg.withValues(alpha: 0.12)),
|
border: Border.all(color: fg.withValues(alpha: 0.12)),
|
||||||
),
|
),
|
||||||
child: Text(
|
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) {
|
String _catTitle(String c) => switch (c) {
|
||||||
'BloodTest' => '抽血化验单',
|
'BloodTest' => '抽血化验单',
|
||||||
'Biochemistry' => '生化检验报告',
|
'Biochemistry' => '生化检验报告',
|
||||||
@@ -786,20 +791,20 @@ String _catTitle(String c) => switch (c) {
|
|||||||
_ => '检查报告',
|
_ => '检查报告',
|
||||||
};
|
};
|
||||||
|
|
||||||
class ReportOriginalPage extends StatelessWidget {
|
class ReportOriginalPage extends ConsumerWidget {
|
||||||
final String url;
|
final String url;
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
|
const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final imageUrl = _absoluteUrl(url);
|
final imageUrl = _absoluteUrl(url);
|
||||||
return GradientScaffold(
|
return GradientScaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back),
|
icon: const Icon(Icons.arrow_back),
|
||||||
onPressed: () => Navigator.pop(context),
|
onPressed: () => popRoute(ref),
|
||||||
),
|
),
|
||||||
title: Text(title),
|
title: Text(title),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -144,19 +144,26 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
// ── 推送总开关 ──
|
// ── 推送总开关 ──
|
||||||
_SectionTitle(title: '推送通知'),
|
_SettingsListSection(
|
||||||
|
title: '推送通知',
|
||||||
|
children: [
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: '允许推送通知',
|
title: '允许推送通知',
|
||||||
subtitle: '关闭后将不再收到任何系统推送',
|
subtitle: '关闭后将不再收到任何系统推送',
|
||||||
value: prefs['pushEnabled'] ?? true,
|
value: prefs['pushEnabled'] ?? true,
|
||||||
|
showDivider: false,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('pushEnabled'),
|
.toggle('pushEnabled'),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── 各类通知开关 ──
|
// ── 各类通知开关 ──
|
||||||
_SectionTitle(title: '通知类型'),
|
_SettingsListSection(
|
||||||
|
title: '通知类型',
|
||||||
|
children: [
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
icon: Icons.medication_rounded,
|
icon: Icons.medication_rounded,
|
||||||
iconBg: AppColors.errorLight,
|
iconBg: AppColors.errorLight,
|
||||||
@@ -197,14 +204,19 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: 'AI 回复通知',
|
title: 'AI 回复通知',
|
||||||
subtitle: 'AI 助手回复时发送通知',
|
subtitle: 'AI 助手回复时发送通知',
|
||||||
value: prefs['aiReply'] ?? false,
|
value: prefs['aiReply'] ?? false,
|
||||||
|
showDivider: false,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('aiReply'),
|
.toggle('aiReply'),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── 健康录入提醒 ──
|
// ── 健康录入提醒 ──
|
||||||
_SectionTitle(title: '健康录入提醒'),
|
_SettingsListSection(
|
||||||
|
title: '健康录入提醒',
|
||||||
|
children: [
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
icon: Icons.alarm_on_rounded,
|
icon: Icons.alarm_on_rounded,
|
||||||
iconBg: const Color(0xFFFEF3C7),
|
iconBg: const Color(0xFFFEF3C7),
|
||||||
@@ -212,6 +224,7 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
title: '每日录入提醒',
|
title: '每日录入提醒',
|
||||||
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||||
value: prefs['healthRecord'] ?? true,
|
value: prefs['healthRecord'] ?? true,
|
||||||
|
showDivider: prefs['healthRecord'] ?? true,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord'),
|
.toggle('healthRecord'),
|
||||||
@@ -248,36 +261,37 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 体重',
|
title: ' · 体重',
|
||||||
value: prefs['healthRecord.weight'] ?? true,
|
value: prefs['healthRecord.weight'] ?? true,
|
||||||
|
showDivider: false,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord.weight'),
|
.toggle('healthRecord.weight'),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── 免打扰时段 ──
|
// ── 免打扰时段 ──
|
||||||
_SectionTitle(title: '免打扰时段'),
|
_SettingsListSection(
|
||||||
|
title: '免打扰时段',
|
||||||
|
children: [
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: '开启免打扰模式',
|
title: '开启免打扰模式',
|
||||||
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
|
||||||
value: dndOn,
|
value: dndOn,
|
||||||
|
showDivider: dndOn,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('dndEnabled'),
|
.toggle('dndEnabled'),
|
||||||
),
|
),
|
||||||
if (dndOn) ...[
|
if (dndOn)
|
||||||
Container(
|
Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4),
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
horizontal: 16,
|
horizontal: 16,
|
||||||
vertical: 14,
|
vertical: 14,
|
||||||
),
|
),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: AppTheme.surface),
|
||||||
color: AppTheme.surface,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
|
||||||
border: Border.all(color: AppColors.border),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -287,7 +301,10 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
final picked = await showAppTimePicker(
|
final picked = await showAppTimePicker(
|
||||||
context,
|
context,
|
||||||
initialTime: const TimeOfDay(hour: 22, minute: 0),
|
initialTime: const TimeOfDay(
|
||||||
|
hour: 22,
|
||||||
|
minute: 0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (picked != null && context.mounted) {
|
if (picked != null && context.mounted) {
|
||||||
ref
|
ref
|
||||||
@@ -314,7 +331,10 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
onTap: () async {
|
onTap: () async {
|
||||||
final picked = await showAppTimePicker(
|
final picked = await showAppTimePicker(
|
||||||
context,
|
context,
|
||||||
initialTime: const TimeOfDay(hour: 8, minute: 0),
|
initialTime: const TimeOfDay(
|
||||||
|
hour: 8,
|
||||||
|
minute: 0,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
if (picked != null && context.mounted) {
|
if (picked != null && context.mounted) {
|
||||||
ref
|
ref
|
||||||
@@ -327,8 +347,8 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
],
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 40),
|
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 {
|
class _SwitchTile extends StatelessWidget {
|
||||||
final IconData? icon;
|
final IconData? icon;
|
||||||
final Color? iconBg;
|
final Color? iconBg;
|
||||||
@@ -365,6 +409,7 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
final String title;
|
final String title;
|
||||||
final String? subtitle;
|
final String? subtitle;
|
||||||
final bool value;
|
final bool value;
|
||||||
|
final bool showDivider;
|
||||||
final ValueChanged<bool> onChanged;
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
const _SwitchTile({
|
const _SwitchTile({
|
||||||
@@ -374,33 +419,38 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
required this.title,
|
required this.title,
|
||||||
this.subtitle,
|
this.subtitle,
|
||||||
required this.value,
|
required this.value,
|
||||||
|
this.showDivider = true,
|
||||||
required this.onChanged,
|
required this.onChanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
border: showDivider
|
||||||
border: Border.all(color: AppColors.border),
|
? Border(
|
||||||
boxShadow: AppColors.cardShadowLight,
|
bottom: BorderSide(
|
||||||
|
color: AppColors.borderLight.withValues(alpha: 0.75),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
if (icon != null) ...[
|
if (icon != null) ...[
|
||||||
Container(
|
Container(
|
||||||
width: 38,
|
width: 34,
|
||||||
height: 38,
|
height: 34,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: iconBg ?? AppColors.iconBg,
|
color: iconBg ?? AppColors.iconBg,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
icon,
|
icon,
|
||||||
size: 23,
|
size: 20,
|
||||||
color: iconColor ?? AppColors.primary,
|
color: iconColor ?? AppColors.primary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -413,15 +463,17 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 16,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w700,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
|
const SizedBox(height: 2),
|
||||||
if (subtitle != null && subtitle!.isNotEmpty)
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
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:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
@@ -37,39 +38,60 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
_SettingsGroup(
|
||||||
children: [
|
children: [
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.bluetooth,
|
icon: LucideIcons.bluetooth,
|
||||||
title: '蓝牙设备',
|
title: '蓝牙设备',
|
||||||
onTap: () => pushRoute(ref, 'devices'),
|
onTap: () => pushRoute(ref, 'devices'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.bell,
|
icon: LucideIcons.bell,
|
||||||
title: '消息通知',
|
title: '消息通知',
|
||||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.info,
|
icon: LucideIcons.info,
|
||||||
title: '关于小脉健康',
|
title: '关于小脉健康',
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
|
||||||
_SettingsTile(
|
_SettingsTile(
|
||||||
icon: LucideIcons.shield,
|
icon: LucideIcons.shield,
|
||||||
title: '隐私协议',
|
title: '隐私协议',
|
||||||
onTap: () =>
|
onTap: () => pushRoute(
|
||||||
pushRoute(ref, 'staticText', params: {'type': 'privacy'}),
|
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(
|
_SettingsTile(
|
||||||
icon: Icons.description_outlined,
|
icon: Icons.description_outlined,
|
||||||
title: '服务协议',
|
title: '服务协议',
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
|
pushRoute(ref, 'staticText', params: {'type': 'terms'}),
|
||||||
),
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _deleteAccount(context, ref),
|
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 {
|
class _SettingsTile extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -186,17 +241,10 @@ class _SettingsTile extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Material(
|
return Material(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(14),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
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(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(icon, color: Colors.black, size: 22),
|
Icon(icon, color: Colors.black, size: 22),
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ class ChatMessage {
|
|||||||
this.confirmed = false,
|
this.confirmed = false,
|
||||||
});
|
});
|
||||||
bool get isUser => role == 'user';
|
bool get isUser => role == 'user';
|
||||||
|
bool get isReadOnly => metadata?['readOnly'] == true;
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ActiveAgent {
|
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>(
|
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(
|
||||||
ChatNotifier.new,
|
ChatNotifier.new,
|
||||||
);
|
);
|
||||||
@@ -85,8 +76,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||||
Completer<void>? _streamDone;
|
Completer<void>? _streamDone;
|
||||||
ActiveAgent? _lastTriggeredAgent;
|
ActiveAgent? _lastTriggeredAgent;
|
||||||
|
Timer? _agentWelcomeTimer;
|
||||||
void markNeedsRebuild() => state = state.copyWith();
|
String? _pendingAgentTriggerMessageId;
|
||||||
|
|
||||||
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
/// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。
|
||||||
/// 历史记录页一键清空 / 删除当前会话时调用。
|
/// 历史记录页一键清空 / 删除当前会话时调用。
|
||||||
@@ -94,7 +85,6 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
await _cancelActiveStream();
|
await _cancelActiveStream();
|
||||||
_lastTriggeredAgent = null;
|
_lastTriggeredAgent = null;
|
||||||
state = const ChatState();
|
state = const ChatState();
|
||||||
ref.read(selectedAgentProvider.notifier).select(null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
|
||||||
@@ -102,6 +92,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
final msgs = state.messages.toList();
|
final msgs = state.messages.toList();
|
||||||
final i = msgs.indexWhere((m) => m.id == id);
|
final i = msgs.indexWhere((m) => m.id == id);
|
||||||
if (i < 0) return '确认卡片不存在';
|
if (i < 0) return '确认卡片不存在';
|
||||||
|
if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看';
|
||||||
|
|
||||||
final rawIds = msgs[i].metadata?['confirmationIds'];
|
final rawIds = msgs[i].metadata?['confirmationIds'];
|
||||||
final confirmationIds = rawIds is List
|
final confirmationIds = rawIds is List
|
||||||
@@ -142,6 +133,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
ChatState build() {
|
ChatState build() {
|
||||||
ref.onDispose(() {
|
ref.onDispose(() {
|
||||||
_subscription?.cancel();
|
_subscription?.cancel();
|
||||||
|
_agentWelcomeTimer?.cancel();
|
||||||
_subscription = null;
|
_subscription = null;
|
||||||
if (_streamDone != null && !_streamDone!.isCompleted) {
|
if (_streamDone != null && !_streamDone!.isCompleted) {
|
||||||
_streamDone!.complete();
|
_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 调用的工具自动切换智能体胶囊
|
/// 根据 AI 调用的工具自动切换智能体胶囊
|
||||||
void _switchAgentByTool(String tool) {
|
void _switchAgentByTool(String tool) {
|
||||||
ActiveAgent? agent;
|
ActiveAgent? agent;
|
||||||
@@ -203,13 +187,13 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (agent != null) {
|
if (agent != null) {
|
||||||
ref.read(selectedAgentProvider.notifier).select(agent);
|
|
||||||
state = state.copyWith(activeAgent: agent);
|
state = state.copyWith(activeAgent: agent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> loadConversation(String convId) async {
|
Future<String?> loadConversation(String convId) async {
|
||||||
await _cancelActiveStream();
|
await _cancelActiveStream();
|
||||||
|
_cancelPendingAgentWelcome();
|
||||||
try {
|
try {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/ai/conversations/$convId');
|
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'
|
final role = map['role']?.toString().toLowerCase() == 'user'
|
||||||
? 'user'
|
? 'user'
|
||||||
: 'assistant';
|
: 'assistant';
|
||||||
final metadata = _parseMetadata(map['metadataJson']);
|
final metadata = _parseMetadata(map['metadataJson']) ?? {};
|
||||||
|
metadata['readOnly'] = true;
|
||||||
return ChatMessage(
|
return ChatMessage(
|
||||||
id: map['id']?.toString() ?? '',
|
id: map['id']?.toString() ?? '',
|
||||||
role: role,
|
role: role,
|
||||||
@@ -229,6 +214,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
type: _messageTypeFromMetadata(metadata),
|
type: _messageTypeFromMetadata(metadata),
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
confirmed: metadata['confirmationIds'] is! List,
|
||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
@@ -237,11 +223,14 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
conversationId: convId,
|
conversationId: convId,
|
||||||
activeAgent: ActiveAgent.default_,
|
activeAgent: ActiveAgent.default_,
|
||||||
);
|
);
|
||||||
ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_);
|
return null;
|
||||||
} catch (_) {}
|
} catch (_) {
|
||||||
|
return '会话加载失败,请稍后重试';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void insertAgentWelcome(ActiveAgent agent) {
|
void insertAgentWelcome(ActiveAgent agent) {
|
||||||
|
_pendingAgentTriggerMessageId = null;
|
||||||
state = state.copyWith(
|
state = state.copyWith(
|
||||||
messages: [
|
messages: [
|
||||||
...state.messages,
|
...state.messages,
|
||||||
@@ -260,7 +249,12 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
/// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI
|
/// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI
|
||||||
/// 重复点击同一胶囊不重复弹卡片
|
/// 重复点击同一胶囊不重复弹卡片
|
||||||
void triggerAgent(ActiveAgent agent, String label) {
|
void triggerAgent(ActiveAgent agent, String label) {
|
||||||
if (_lastTriggeredAgent == agent) return;
|
if (_pendingAgentTriggerMessageId != null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
_lastTriggeredAgent = agent;
|
_lastTriggeredAgent = agent;
|
||||||
|
|
||||||
final userMsg = ChatMessage(
|
final userMsg = ChatMessage(
|
||||||
@@ -269,18 +263,35 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
content: label,
|
content: label,
|
||||||
createdAt: DateTime.now(),
|
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);
|
insertAgentWelcome(agent);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _cancelPendingAgentWelcome() {
|
||||||
|
_agentWelcomeTimer?.cancel();
|
||||||
|
_agentWelcomeTimer = null;
|
||||||
|
_pendingAgentTriggerMessageId = null;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> sendImage(String imagePath, String text) async {
|
Future<void> sendImage(String imagePath, String text) async {
|
||||||
if (state.isStreaming) return;
|
if (state.isStreaming) return;
|
||||||
final file = File(imagePath);
|
final file = File(imagePath);
|
||||||
if (!await file.exists()) return;
|
if (!await file.exists()) return;
|
||||||
_lastTriggeredAgent = null;
|
_lastTriggeredAgent = null;
|
||||||
|
_cancelPendingAgentWelcome();
|
||||||
|
|
||||||
// 先显示用户消息(本地显示图片路径)
|
// 先显示用户消息(本地显示图片路径)
|
||||||
final userMsg = ChatMessage(
|
final userMsg = ChatMessage(
|
||||||
@@ -290,7 +301,10 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
metadata: {'localImagePath': imagePath},
|
metadata: {'localImagePath': imagePath},
|
||||||
);
|
);
|
||||||
state = state.copyWith(messages: [...state.messages, userMsg]);
|
state = state.copyWith(
|
||||||
|
messages: [...state.messages, userMsg],
|
||||||
|
isStreaming: true,
|
||||||
|
);
|
||||||
|
|
||||||
// 异步上传图片
|
// 异步上传图片
|
||||||
String? uploadedUrl;
|
String? uploadedUrl;
|
||||||
@@ -328,6 +342,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||||
|
state = state.copyWith(isStreaming: false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -342,6 +357,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
final file = File(pdfPath);
|
final file = File(pdfPath);
|
||||||
if (!await file.exists()) return;
|
if (!await file.exists()) return;
|
||||||
_lastTriggeredAgent = null;
|
_lastTriggeredAgent = null;
|
||||||
|
_cancelPendingAgentWelcome();
|
||||||
|
|
||||||
final userMsg = ChatMessage(
|
final userMsg = ChatMessage(
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||||
@@ -350,7 +366,10 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
metadata: {'pdfFileName': fileName},
|
metadata: {'pdfFileName': fileName},
|
||||||
);
|
);
|
||||||
state = state.copyWith(messages: [...state.messages, userMsg]);
|
state = state.copyWith(
|
||||||
|
messages: [...state.messages, userMsg],
|
||||||
|
isStreaming: true,
|
||||||
|
);
|
||||||
|
|
||||||
String? uploadedUrl;
|
String? uploadedUrl;
|
||||||
try {
|
try {
|
||||||
@@ -382,6 +401,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
createdAt: DateTime.now(),
|
createdAt: DateTime.now(),
|
||||||
);
|
);
|
||||||
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
state = state.copyWith(messages: [...state.messages, errorMsg]);
|
||||||
|
state = state.copyWith(isStreaming: false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -391,6 +411,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
|||||||
Future<void> sendMessage(String text) async {
|
Future<void> sendMessage(String text) async {
|
||||||
if (text.trim().isEmpty || state.isStreaming) return;
|
if (text.trim().isEmpty || state.isStreaming) return;
|
||||||
_lastTriggeredAgent = null;
|
_lastTriggeredAgent = null;
|
||||||
|
_cancelPendingAgentWelcome();
|
||||||
|
|
||||||
final userMsg = ChatMessage(
|
final userMsg = ChatMessage(
|
||||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||||
|
|||||||
@@ -24,7 +24,8 @@ class ConversationListItem {
|
|||||||
title: json['title']?.toString(),
|
title: json['title']?.toString(),
|
||||||
summary: json['summary']?.toString(),
|
summary: json['summary']?.toString(),
|
||||||
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
|
messageCount: (json['messageCount'] as num?)?.toInt() ?? 0,
|
||||||
updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
|
updatedAt:
|
||||||
|
DateTime.tryParse(json['updatedAt']?.toString() ?? '') ??
|
||||||
DateTime.now(),
|
DateTime.now(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -33,15 +34,20 @@ class ConversationListItem {
|
|||||||
class ConversationHistoryNotifier
|
class ConversationHistoryNotifier
|
||||||
extends AsyncNotifier<List<ConversationListItem>> {
|
extends AsyncNotifier<List<ConversationListItem>> {
|
||||||
@override
|
@override
|
||||||
Future<List<ConversationListItem>> build() => _fetch();
|
Future<List<ConversationListItem>> build() {
|
||||||
|
ref.watch(chatProvider.select((state) => state.conversationId));
|
||||||
|
return _fetch();
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<ConversationListItem>> _fetch() async {
|
Future<List<ConversationListItem>> _fetch() async {
|
||||||
final api = ref.read(apiClientProvider);
|
final api = ref.read(apiClientProvider);
|
||||||
final res = await api.get('/api/ai/conversations');
|
final res = await api.get('/api/ai/conversations');
|
||||||
final raw = (res.data['data'] as List?) ?? const [];
|
final raw = (res.data['data'] as List?) ?? const [];
|
||||||
|
final currentConversationId = ref.read(chatProvider).conversationId;
|
||||||
return raw
|
return raw
|
||||||
.whereType<Map>()
|
.whereType<Map>()
|
||||||
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
|
.map((m) => ConversationListItem.fromJson(Map<String, dynamic>.from(m)))
|
||||||
|
.where((item) => item.id != currentConversationId)
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -79,7 +85,8 @@ class ConversationHistoryNotifier
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final conversationHistoryProvider = AsyncNotifierProvider<
|
final conversationHistoryProvider =
|
||||||
ConversationHistoryNotifier, List<ConversationListItem>>(
|
AsyncNotifierProvider<
|
||||||
ConversationHistoryNotifier.new,
|
ConversationHistoryNotifier,
|
||||||
);
|
List<ConversationListItem>
|
||||||
|
>(ConversationHistoryNotifier.new);
|
||||||
|
|||||||
@@ -60,11 +60,6 @@ final latestHealthProvider = FutureProvider.autoDispose<Map<String, dynamic>>((
|
|||||||
return service.getLatest();
|
return service.getLatest();
|
||||||
});
|
});
|
||||||
|
|
||||||
/// AI 录入数据后调用,刷新侧边栏
|
|
||||||
void refreshHealthData(WidgetRef ref) {
|
|
||||||
ref.invalidate(latestHealthProvider);
|
|
||||||
ref.invalidate(medicationListProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 用药列表 Provider
|
/// 用药列表 Provider
|
||||||
final medicationListProvider =
|
final medicationListProvider =
|
||||||
@@ -87,14 +82,6 @@ final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((
|
|||||||
return service.getDoctors().timeout(const Duration(seconds: 8));
|
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
|
/// 当前运动计划 Provider
|
||||||
final currentExercisePlanProvider =
|
final currentExercisePlanProvider =
|
||||||
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
|
FutureProvider.autoDispose<Map<String, dynamic>?>((ref) async {
|
||||||
|
|||||||
@@ -21,10 +21,6 @@ final healthBleServiceProvider = Provider<HealthBleService>((ref) {
|
|||||||
return service;
|
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 {
|
class DeviceBindState {
|
||||||
final List<BoundBleDevice> devices;
|
final List<BoundBleDevice> devices;
|
||||||
final bool isConnected;
|
final bool isConnected;
|
||||||
|
|||||||
@@ -35,6 +35,10 @@ class HealthBleService {
|
|||||||
return BleDeviceIdentifier.typeFromScan(result);
|
return BleDeviceIdentifier.typeFromScan(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static bool isSyncImplemented(BleDeviceType type) {
|
||||||
|
return type == BleDeviceType.bloodPressure;
|
||||||
|
}
|
||||||
|
|
||||||
Future<BoundBleDevice> connectAndIdentify({
|
Future<BoundBleDevice> connectAndIdentify({
|
||||||
required BluetoothDevice device,
|
required BluetoothDevice device,
|
||||||
required String name,
|
required String name,
|
||||||
@@ -65,6 +69,9 @@ class HealthBleService {
|
|||||||
if (connectedType != bound.type) {
|
if (connectedType != bound.type) {
|
||||||
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
|
throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致');
|
||||||
}
|
}
|
||||||
|
if (!isSyncImplemented(bound.type)) {
|
||||||
|
throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通');
|
||||||
|
}
|
||||||
|
|
||||||
return switch (bound.type) {
|
return switch (bound.type) {
|
||||||
BleDeviceType.bloodPressure => BloodPressureSyncResult(
|
BleDeviceType.bloodPressure => BloodPressureSyncResult(
|
||||||
|
|||||||
@@ -1,40 +1,15 @@
|
|||||||
import '../core/api_client.dart';
|
import '../core/api_client.dart';
|
||||||
|
|
||||||
/// 健康数据服务
|
|
||||||
class HealthService {
|
class HealthService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
HealthService(this._api);
|
HealthService(this._api);
|
||||||
|
|
||||||
/// 获取各指标最新值
|
|
||||||
Future<Map<String, dynamic>> getLatest() async {
|
Future<Map<String, dynamic>> getLatest() async {
|
||||||
final res = await _api.get('/api/health-records/latest');
|
final res = await _api.get('/api/health-records/latest');
|
||||||
return res.data['data'] ?? {};
|
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 {
|
class UserService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
UserService(this._api);
|
UserService(this._api);
|
||||||
@@ -44,8 +19,15 @@ class UserService {
|
|||||||
return res.data['data'];
|
return res.data['data'];
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateProfile({String? name, String? gender, String? birthDate}) async {
|
Future<void> updateProfile({
|
||||||
await _api.put('/api/user/profile', data: {'name': name, 'gender': gender, 'birthDate': birthDate});
|
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 {
|
Future<Map<String, dynamic>?> getHealthArchive() async {
|
||||||
@@ -62,7 +44,6 @@ class UserService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 用药服务
|
|
||||||
class MedicationService {
|
class MedicationService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
MedicationService(this._api);
|
MedicationService(this._api);
|
||||||
@@ -81,14 +62,13 @@ class MedicationService {
|
|||||||
await _api.put('/api/medications/$id', data: data);
|
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 {
|
Future<List<Map<String, dynamic>>> getMedications(String filter) async {
|
||||||
final params = <String, dynamic>{};
|
final params = <String, dynamic>{};
|
||||||
if (filter.isNotEmpty) params['filter'] = filter;
|
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? ?? [];
|
final list = res.data['data'] as List? ?? [];
|
||||||
return list.cast<Map<String, dynamic>>();
|
return list.cast<Map<String, dynamic>>();
|
||||||
}
|
}
|
||||||
@@ -108,12 +88,14 @@ class MedicationService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 饮食服务
|
|
||||||
class DietService {
|
class DietService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
DietService(this._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>{};
|
final params = <String, dynamic>{};
|
||||||
if (date != null) params['date'] = date;
|
if (date != null) params['date'] = date;
|
||||||
if (mealType != null) params['mealType'] = mealType;
|
if (mealType != null) params['mealType'] = mealType;
|
||||||
@@ -135,7 +117,6 @@ class DietService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 问诊服务
|
|
||||||
class ConsultationService {
|
class ConsultationService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
ConsultationService(this._api);
|
ConsultationService(this._api);
|
||||||
@@ -145,31 +126,8 @@ class ConsultationService {
|
|||||||
final list = res.data['data'] as List? ?? [];
|
final list = res.data['data'] as List? ?? [];
|
||||||
return list.cast<Map<String, dynamic>>();
|
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 {
|
class FollowUpService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
FollowUpService(this._api);
|
FollowUpService(this._api);
|
||||||
@@ -181,7 +139,6 @@ class FollowUpService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 运动服务
|
|
||||||
class ExerciseService {
|
class ExerciseService {
|
||||||
final ApiClient _api;
|
final ApiClient _api;
|
||||||
ExerciseService(this._api);
|
ExerciseService(this._api);
|
||||||
@@ -191,10 +148,6 @@ class ExerciseService {
|
|||||||
return res.data['data'];
|
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 {
|
Future<void> createPlanSimple(Map<String, dynamic> data) async {
|
||||||
await _api.post('/api/exercise-plans', data: data);
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../core/app_colors.dart';
|
|
||||||
import '../core/app_design_tokens.dart';
|
import '../core/app_design_tokens.dart';
|
||||||
|
|
||||||
class AppStatusBadge extends StatelessWidget {
|
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 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../core/app_colors.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 {
|
class SwipeDeleteTile extends StatefulWidget {
|
||||||
final Widget child;
|
final Widget child;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onDelete;
|
||||||
final VoidCallback? onTap;
|
final VoidCallback? onTap;
|
||||||
final EdgeInsetsGeometry margin;
|
final EdgeInsetsGeometry margin;
|
||||||
|
|
||||||
const SwipeDeleteTile({
|
const SwipeDeleteTile({
|
||||||
super.key,
|
super.key,
|
||||||
required this.child,
|
required this.child,
|
||||||
@@ -119,6 +15,7 @@ class SwipeDeleteTile extends StatefulWidget {
|
|||||||
this.onTap,
|
this.onTap,
|
||||||
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
State<SwipeDeleteTile> createState() => _SwipeDeleteTileState();
|
State<SwipeDeleteTile> createState() => _SwipeDeleteTileState();
|
||||||
}
|
}
|
||||||
@@ -127,18 +24,14 @@ class _SwipeDeleteTileState extends State<SwipeDeleteTile>
|
|||||||
with SingleTickerProviderStateMixin {
|
with SingleTickerProviderStateMixin {
|
||||||
double _dx = 0;
|
double _dx = 0;
|
||||||
static const _maxSlide = 80.0;
|
static const _maxSlide = 80.0;
|
||||||
static const _threshold = 24.0; // 30% of 80
|
static const _threshold = 24.0;
|
||||||
|
|
||||||
void _onDragUpdate(DragUpdateDetails d) {
|
void _onDragUpdate(DragUpdateDetails d) {
|
||||||
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
|
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragEnd(DragEndDetails d) {
|
void _onDragEnd(DragEndDetails d) {
|
||||||
if (_dx < -_threshold) {
|
setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0);
|
||||||
setState(() => _dx = -_maxSlide);
|
|
||||||
} else {
|
|
||||||
setState(() => _dx = 0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@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 {
|
class DrawerShell extends StatelessWidget {
|
||||||
final double widthFactor;
|
final double widthFactor;
|
||||||
final Widget child;
|
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
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@@ -16,6 +22,7 @@ class DrawerShell extends StatelessWidget {
|
|||||||
borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)),
|
borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)),
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
|
if (showBackground) ...[
|
||||||
Positioned.fill(
|
Positioned.fill(
|
||||||
child: Image.asset(
|
child: Image.asset(
|
||||||
'assets/branding/drawer_background_v1.png',
|
'assets/branding/drawer_background_v1.png',
|
||||||
@@ -39,6 +46,8 @@ class DrawerShell extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
] else
|
||||||
|
const Positioned.fill(child: ColoredBox(color: Colors.white)),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
import '../core/app_design_tokens.dart';
|
import '../core/app_design_tokens.dart';
|
||||||
import '../core/app_theme.dart';
|
|
||||||
|
|
||||||
class EnterpriseStat {
|
class EnterpriseStat {
|
||||||
final String label;
|
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(
|
return DrawerShell(
|
||||||
widthFactor: 0.9,
|
widthFactor: 0.9,
|
||||||
child: SafeArea(
|
showBackground: false,
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
physics: const BouncingScrollPhysics(),
|
physics: const BouncingScrollPhysics(),
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverPadding(
|
SliverToBoxAdapter(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 16, 12, 24),
|
child: _DrawerHero(
|
||||||
sliver: SliverList.list(
|
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: [
|
children: [
|
||||||
_AccountHeader(user: auth.user, ref: ref),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_HealthDashboard(latestHealth: latestHealth, ref: ref),
|
|
||||||
const SizedBox(height: 18),
|
|
||||||
_NavigationSection(ref: ref),
|
_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),
|
_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(
|
return _LightSection(
|
||||||
title: '常用功能',
|
title: '常用功能',
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 2,
|
crossAxisCount: 4,
|
||||||
mainAxisExtent: 48,
|
mainAxisExtent: 82,
|
||||||
mainAxisSpacing: 8,
|
mainAxisSpacing: 7,
|
||||||
crossAxisSpacing: 8,
|
crossAxisSpacing: 8,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
itemBuilder: (context, index) {
|
||||||
@@ -412,38 +482,42 @@ class _NavTile extends StatelessWidget {
|
|||||||
return InkWell(
|
return InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
child: Container(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
color: Colors.white.withValues(alpha: 0.54),
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
borderRadius: AppRadius.mdBorder,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 30,
|
width: 52,
|
||||||
height: 30,
|
height: 52,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: _colors,
|
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),
|
child: Icon(item.icon, color: Colors.white, size: 26),
|
||||||
Expanded(
|
),
|
||||||
child: Text(
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
_title,
|
_title,
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
height: 1.1,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -471,8 +545,8 @@ class _LightSection extends StatelessWidget {
|
|||||||
child: Text(
|
child: Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -480,7 +554,7 @@ class _LightSection extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 2),
|
||||||
child,
|
child,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -516,10 +590,6 @@ class _Panel extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
borderRadius: AppRadius.xlBorder,
|
borderRadius: AppRadius.xlBorder,
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white.withValues(alpha: 0.82),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
boxShadow: AppShadows.soft,
|
boxShadow: AppShadows.soft,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
@@ -532,7 +602,7 @@ class _Panel extends StatelessWidget {
|
|||||||
title,
|
title,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 18,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w800,
|
||||||
color: titleColor,
|
color: titleColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -636,24 +706,53 @@ class _NavItem {
|
|||||||
required this.colors,
|
required this.colors,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
class _HistorySection extends ConsumerWidget {
|
|
||||||
|
class _HistorySection extends ConsumerStatefulWidget {
|
||||||
final WidgetRef ref;
|
final WidgetRef ref;
|
||||||
const _HistorySection({required this.ref});
|
const _HistorySection({required this.ref});
|
||||||
|
|
||||||
static const int _previewCount = 5;
|
static const int _previewCount = 5;
|
||||||
|
|
||||||
@override
|
@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);
|
final async = ref.watch(conversationHistoryProvider);
|
||||||
|
|
||||||
return _Panel(
|
return _LightSection(
|
||||||
title: '对话记录',
|
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(
|
child: async.when(
|
||||||
loading: () => const Padding(
|
loading: () => const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 14),
|
padding: EdgeInsets.symmetric(vertical: 14),
|
||||||
@@ -682,31 +781,43 @@ class _HistorySection extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
final preview = list.take(_previewCount).toList();
|
final preview = list.take(_HistorySection._previewCount).toList();
|
||||||
return Column(
|
return ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(minHeight: 48),
|
||||||
|
child: Stack(
|
||||||
|
clipBehavior: Clip.none,
|
||||||
|
children: [
|
||||||
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
for (var i = 0; i < preview.length; i++)
|
for (var i = 0; i < preview.length; i++)
|
||||||
_DrawerHistoryTile(
|
_DrawerHistoryTile(
|
||||||
item: preview[i],
|
item: preview[i],
|
||||||
isLast: i == preview.length - 1,
|
isLast: i == preview.length - 1,
|
||||||
|
selecting: _selecting,
|
||||||
|
selected: _selectedId == preview[i].id,
|
||||||
onTap: () async {
|
onTap: () async {
|
||||||
Navigator.of(context).maybePop(); // 关闭侧边栏
|
if (_selecting) {
|
||||||
await ref
|
_toggleSelect(preview[i].id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final error = await ref
|
||||||
.read(chatProvider.notifier)
|
.read(chatProvider.notifier)
|
||||||
.loadConversation(preview[i].id);
|
.loadConversation(preview[i].id);
|
||||||
},
|
if (!context.mounted) return;
|
||||||
onDelete: () async {
|
if (error != null) {
|
||||||
try {
|
AppToast.show(
|
||||||
await ref
|
context,
|
||||||
.read(conversationHistoryProvider.notifier)
|
error,
|
||||||
.deleteOne(preview[i].id);
|
type: AppToastType.error,
|
||||||
} catch (_) {
|
);
|
||||||
// 失败时 provider 已回滚状态,UI 自然恢复
|
return;
|
||||||
}
|
}
|
||||||
|
Navigator.of(context).maybePop();
|
||||||
},
|
},
|
||||||
|
onLongPress: () => _enterSelect(preview[i].id),
|
||||||
),
|
),
|
||||||
if (list.length > _previewCount) ...[
|
if (list.length > _HistorySection._previewCount) ...[
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
@@ -717,7 +828,10 @@ class _HistorySection extends ConsumerWidget {
|
|||||||
},
|
},
|
||||||
borderRadius: BorderRadius.circular(999),
|
borderRadius: BorderRadius.circular(999),
|
||||||
child: const Padding(
|
child: const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6),
|
padding: EdgeInsets.symmetric(
|
||||||
|
horizontal: 8,
|
||||||
|
vertical: 6,
|
||||||
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'查看全部',
|
'查看全部',
|
||||||
style: TextStyle(
|
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 {
|
class _HistorySelectionBar extends StatelessWidget {
|
||||||
final WidgetRef ref;
|
final bool deleting;
|
||||||
final AsyncValue<List<ConversationListItem>> async;
|
final VoidCallback onCancel;
|
||||||
const _HistoryActions({required this.ref, required this.async});
|
final VoidCallback onDelete;
|
||||||
|
|
||||||
|
const _HistorySelectionBar({
|
||||||
|
required this.deleting,
|
||||||
|
required this.onCancel,
|
||||||
|
required this.onDelete,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final hasItems = (async.asData?.value.isNotEmpty ?? false);
|
return Align(
|
||||||
return Row(
|
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,
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
InkWell(
|
_HistoryActionButton(
|
||||||
onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(),
|
icon: Icons.delete_outline_rounded,
|
||||||
borderRadius: BorderRadius.circular(999),
|
label: deleting ? '删除中' : '删除',
|
||||||
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,
|
|
||||||
color: AppColors.error,
|
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 {
|
class _HistoryActionButton extends StatelessWidget {
|
||||||
final ok = await showDialog<bool>(
|
final IconData icon;
|
||||||
context: context,
|
final String label;
|
||||||
builder: (ctx) => AlertDialog(
|
final Color color;
|
||||||
title: const Text('清空全部历史'),
|
final VoidCallback? onTap;
|
||||||
content: const Text('清空后无法恢复,确认继续?'),
|
|
||||||
actions: [
|
const _HistoryActionButton({
|
||||||
TextButton(
|
required this.icon,
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
required this.label,
|
||||||
child: const Text('取消'),
|
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 {
|
class _DrawerHistoryTile extends StatelessWidget {
|
||||||
final ConversationListItem item;
|
final ConversationListItem item;
|
||||||
final bool isLast;
|
final bool isLast;
|
||||||
|
final bool selecting;
|
||||||
|
final bool selected;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
final VoidCallback onDelete;
|
final VoidCallback onLongPress;
|
||||||
const _DrawerHistoryTile({
|
const _DrawerHistoryTile({
|
||||||
required this.item,
|
required this.item,
|
||||||
required this.isLast,
|
required this.isLast,
|
||||||
|
required this.selecting,
|
||||||
|
required this.selected,
|
||||||
required this.onTap,
|
required this.onTap,
|
||||||
required this.onDelete,
|
required this.onLongPress,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Dismissible(
|
return InkWell(
|
||||||
key: ValueKey('drawer-${item.id}'),
|
|
||||||
direction: DismissDirection.endToStart,
|
|
||||||
background: Container(
|
|
||||||
alignment: Alignment.centerRight,
|
|
||||||
padding: const EdgeInsets.only(right: 14),
|
|
||||||
margin: EdgeInsets.only(bottom: isLast ? 0 : 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.error,
|
|
||||||
borderRadius: BorderRadius.circular(16),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.delete_outline, color: Colors.white),
|
|
||||||
),
|
|
||||||
confirmDismiss: (_) async {
|
|
||||||
onDelete();
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
child: InkWell(
|
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(14),
|
onLongPress: onLongPress,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: EdgeInsets.only(bottom: isLast ? 0 : 6),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
|
decoration: selected
|
||||||
decoration: BoxDecoration(
|
? BoxDecoration(
|
||||||
color: Colors.white.withValues(alpha: 0.72),
|
color: const Color(0xFFF1F3F7),
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(8),
|
||||||
border: Border.all(color: Colors.white.withValues(alpha: 0.9)),
|
)
|
||||||
),
|
: null,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -861,10 +1014,12 @@ class _DrawerHistoryTile extends StatelessWidget {
|
|||||||
_displaySummary(item),
|
_displaySummary(item),
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: selected
|
||||||
|
? AppColors.textSecondary
|
||||||
|
: AppColors.textPrimary,
|
||||||
height: 1.2,
|
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