feat: UI全面改造 + 后端多项修复

【后端修复】
- 删除diet/consultation agent假工具声明
- 修复DayOfWeek实体注释
- 修复Vision API content序列化
- cleanup_service级联删除修复
- 用药提醒时区偏差修复
- 统一DateTime处理(UtcNow+8)
- 新增UTC DateTime JSON转换器

【前端UI重构】
- 配色体系全面更新(#8B5CF6淡紫+#F0ECFF背景)
- 登录页重设计
- 首页重设计(透明顶栏、渐变背景、胶囊输入区)
- 聊天卡片加白蓝边框、渐变标题
- 侧边栏重构(渐变背景、合并顶部、删除底部设置)
- 确认卡片可编辑字段恢复
- 所有子页面加返回按钮
- catch异常加日志
- 删除后refresh provider缓存
This commit is contained in:
MingNian
2026-06-10 18:27:38 +08:00
parent 3964cf2bcb
commit 63d2092c24
46 changed files with 2003 additions and 1431 deletions

View File

@@ -7,7 +7,7 @@ public sealed class ExercisePlan
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid UserId { get; set; } public Guid UserId { get; set; }
public DateOnly WeekStartDate { get; set; } // 本周一 public DateOnly WeekStartDate { get; set; } // 起始日期
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow; public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
@@ -22,7 +22,7 @@ public sealed class ExercisePlanItem
{ {
public Guid Id { get; set; } public Guid Id { get; set; }
public Guid PlanId { get; set; } public Guid PlanId { get; set; }
public int DayOfWeek { get; set; } // 0=周, 6=周 public int DayOfWeek { get; set; } // C# DayOfWeek: 0=周, 6=周
public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳 public string ExerciseType { get; set; } = string.Empty; // 散步/慢跑/游泳
public int DurationMinutes { get; set; } public int DurationMinutes { get; set; }
public bool IsCompleted { get; set; } public bool IsCompleted { get; set; }

View File

@@ -1,20 +1,11 @@
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// AI 问诊 Agent 工具处理器——转医生 /// 问诊 Agent 工具处理器(问诊转医生走专门界面,此处只保留档案和记录查询)
/// </summary> /// </summary>
public static class ConsultationAgentHandler public static class ConsultationAgentHandler
{ {
public static readonly ToolDefinition RequestDoctorTool = new() public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool];
{
Function = new()
{
Name = "request_doctor", Description = "请求转接真人医生",
Parameters = new { type = "object", properties = new { reason = new { type = "string" }, urgency_level = new { type = "string" } } }
}
};
public static List<ToolDefinition> Tools => [CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.CheckArchiveTool, RequestDoctorTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{ {

View File

@@ -1,16 +1,11 @@
namespace Health.Infrastructure.AI.AgentHandlers; namespace Health.Infrastructure.AI.AgentHandlers;
/// <summary> /// <summary>
/// 饮食 Agent 工具处理器——食物估算 /// 饮食 Agent 工具处理器(饮食拍照走专门界面,此处只保留档案查询)
/// </summary> /// </summary>
public static class DietAgentHandler public static class DietAgentHandler
{ {
public static readonly ToolDefinition EstimateFoodTool = new() public static List<ToolDefinition> Tools => [CommonAgentHandler.CheckArchiveTool];
{
Function = new() { Name = "estimate_food_text", Description = "根据文字描述估算食物份量和热量", Parameters = new { type = "object", properties = new { text = new { type = "string" } }, required = new[] { "text" } } }
};
public static List<ToolDefinition> Tools => [EstimateFoodTool, CommonAgentHandler.CheckArchiveTool];
public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId) public static Task<object> Execute(string toolName, JsonElement args, AppDbContext db, Guid userId)
{ {

View File

@@ -40,7 +40,7 @@ public static class ExerciseAgentHandler
switch (action) switch (action)
{ {
case "create": case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now); var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart }; var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items)) if (args.TryGetProperty("items", out var items))
{ {

View File

@@ -71,8 +71,8 @@ public static class MedicationAgentHandler
Name = name, Dosage = dosage, Frequency = frequency, Name = name, Dosage = dosage, Frequency = frequency,
TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)],
Source = MedicationSource.AiEntry, IsActive = true, Source = MedicationSource.AiEntry, IsActive = true,
StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.Now), StartDate = DateOnly.TryParse(startDateStr, out var parsedDate) ? parsedDate : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.Now).AddDays(durationDays) : null, EndDate = durationDays > 0 ? DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)).AddDays(durationDays) : null,
}; };
db.Medications.Add(med); db.Medications.Add(med);
await db.SaveChangesAsync(); await db.SaveChangesAsync();
@@ -100,7 +100,7 @@ public static class MedicationAgentHandler
db.MedicationLogs.Add(new MedicationLog db.MedicationLogs.Add(new MedicationLog
{ {
Id = Guid.NewGuid(), MedicationId = medId, UserId = userId, Id = Guid.NewGuid(), MedicationId = medId, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow, Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
}); });
await db.SaveChangesAsync(); await db.SaveChangesAsync();
return new { success = true }; return new { success = true };

View File

@@ -133,7 +133,7 @@ public sealed class OpenAiCompatibleClient(string baseUrl, string apiKey, string
var userMessage = new ChatMessage var userMessage = new ChatMessage
{ {
Role = "user", Role = "user",
Content = JsonSerializer.Serialize(contentParts, _jsonOptions) Content = contentParts // 数组格式,让 JSON 序列化时保持为数组而非字符串
}; };
messages.Add(userMessage); messages.Add(userMessage);

View File

@@ -126,7 +126,7 @@ public sealed class PromptManager
1. //// record_health_data 1. //// record_health_data
2. estimate_food_text 2. 使
3. manage_medication 3. manage_medication
4. manage_exercise 4. manage_exercise
5. / check_archive / manage_archive 5. / check_archive / manage_archive

View File

@@ -104,7 +104,7 @@ public static class DevDataSeeder
}); });
// ---- 运动计划 ---- // ---- 运动计划 ----
var monday = DateOnly.FromDateTime(DateTime.Now.AddDays(-(int)DateTime.Now.DayOfWeek + 1)); var monday = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8).AddDays(-(int)DateTime.UtcNow.AddHours(8).DayOfWeek + 1));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday }; var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = user.Id, WeekStartDate = monday };
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 0, ExerciseType = "散步", DurationMinutes = 30, IsCompleted = true, CompletedAt = DateTime.UtcNow.AddDays(-1) });
plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 }); plan.Items.Add(new ExercisePlanItem { Id = Guid.NewGuid(), DayOfWeek = 1, ExerciseType = "慢跑", DurationMinutes = 20 });
@@ -119,7 +119,7 @@ public static class DevDataSeeder
var lunch = new DietRecord var lunch = new DietRecord
{ {
Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch, Id = Guid.NewGuid(), UserId = user.Id, MealType = MealType.Lunch,
TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.Now), TotalCalories = 644, HealthScore = 3, RecordedAt = DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
}; };
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 }); lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "米饭", Portion = "约1碗", Calories = 174, SortOrder = 1 });
lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 }); lunch.FoodItems.Add(new DietFoodItem { Id = Guid.NewGuid(), Name = "红烧肉", Portion = "约5块", Calories = 470, Warning = "脂肪含量偏高", SortOrder = 2 });

View File

@@ -17,17 +17,29 @@ public sealed class CleanupService(IServiceScopeFactory scopeFactory, ILogger<Cl
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 清理 30 天前的对话记录 // 清理 30 天前的对话记录(先删消息再删对话,避免 FK 冲突)
var cutoff = DateTime.UtcNow.AddDays(-30); var cutoff = DateTime.UtcNow.AddDays(-30);
var oldConversations = await db.Conversations var oldConvIds = await db.Conversations
.Where(c => c.CreatedAt < cutoff) .Where(c => c.UpdatedAt < cutoff)
.Select(c => c.Id)
.ToListAsync(stoppingToken); .ToListAsync(stoppingToken);
if (oldConversations.Count > 0) if (oldConvIds.Count > 0)
{ {
// 先批量删消息
var oldMessages = await db.ConversationMessages
.Where(m => oldConvIds.Contains(m.ConversationId))
.ToListAsync(stoppingToken);
db.ConversationMessages.RemoveRange(oldMessages);
// 再删对话
var oldConversations = await db.Conversations
.Where(c => oldConvIds.Contains(c.Id))
.ToListAsync(stoppingToken);
db.Conversations.RemoveRange(oldConversations); db.Conversations.RemoveRange(oldConversations);
await db.SaveChangesAsync(stoppingToken); await db.SaveChangesAsync(stoppingToken);
_logger.LogInformation("清理 {Count} 条过期对话", oldConversations.Count); _logger.LogInformation("清理 {Count} 条过期对话", oldConvIds.Count);
} }
// 清理过期验证码 // 清理过期验证码

View File

@@ -31,13 +31,12 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
{ {
using var scope = _scopeFactory.CreateScope(); using var scope = _scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>(); var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
// 使用北京时间UTC+8 // 北京时间今天0点 → 转为UTC
var beijingNow = DateTime.UtcNow.AddHours(8); var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var beijingTime = TimeOnly.FromDateTime(beijingNow); var todayStartUtc = beijingToday.AddHours(-8);
var todayStart = DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc);
// 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内(防止服务重启错过提醒) // 查询:启用的用药计划 AND 服药时间在当前时间前后5分钟窗口内
// 处理跨午夜情况 var beijingTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8));
var windowStart = beijingTime.AddMinutes(-5); var windowStart = beijingTime.AddMinutes(-5);
var medications = await db.Medications var medications = await db.Medications
.Where(m => m.IsActive) .Where(m => m.IsActive)
@@ -47,10 +46,10 @@ public sealed class MedicationReminderService(IServiceScopeFactory scopeFactory,
foreach (var med in medications) foreach (var med in medications)
{ {
// 检查今天是否已打卡 // 检查今天是否已打卡(用正确的北京时间区间)
var alreadyLogged = await db.MedicationLogs var alreadyLogged = await db.MedicationLogs
.AnyAsync(l => l.MedicationId == med.Id .AnyAsync(l => l.MedicationId == med.Id
&& l.CreatedAt >= todayStart && l.CreatedAt >= todayStartUtc
&& l.Status == MedicationLogStatus.Taken, ct); && l.Status == MedicationLogStatus.Taken, ct);
if (alreadyLogged) continue; if (alreadyLogged) continue;

View File

@@ -291,7 +291,7 @@ public static class AiChatEndpoints
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
// 记录VLM原始返回用于排查 // 记录VLM原始返回用于排查
var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); var uploadsDir2 = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.Now:HHmmss}.txt"); var logPath = Path.Combine(uploadsDir2, $"vlm_log_{DateTime.UtcNow:HHmmss}.txt");
await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct); await File.WriteAllTextAsync(logPath, $"MODEL: {Environment.GetEnvironmentVariable("VLM_MODEL")}\nIMAGE_SIZE: {imageUrls.FirstOrDefault()?.Length ?? 0}\nRESPONSE:\n{result}", ct);
return Results.Ok(new { code = 0, data = result, message = (string?)null }); return Results.Ok(new { code = 0, data = result, message = (string?)null });
} }
@@ -340,7 +340,6 @@ public static class AiChatEndpoints
AgentType.Unified => [ AgentType.Unified => [
HealthDataAgentHandler.RecordHealthDataTool, HealthDataAgentHandler.RecordHealthDataTool,
CommonAgentHandler.QueryHealthRecordsTool, CommonAgentHandler.QueryHealthRecordsTool,
DietAgentHandler.EstimateFoodTool,
MedicationAgentHandler.ManageMedicationTool, MedicationAgentHandler.ManageMedicationTool,
ExerciseAgentHandler.ManageExerciseTool, ExerciseAgentHandler.ManageExerciseTool,
CommonAgentHandler.CheckArchiveTool, CommonAgentHandler.CheckArchiveTool,
@@ -359,13 +358,11 @@ public static class AiChatEndpoints
"query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId), "query_health_records" => CommonAgentHandler.Execute(toolName, root, db, userId),
"check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId), "check_archive" => CommonAgentHandler.Execute(toolName, root, db, userId),
"manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId), "manage_medication" => MedicationAgentHandler.Execute(toolName, root, db, userId),
"estimate_food_text" => DietAgentHandler.Execute(toolName, root, db, userId),
"analyze_report" => visionClient != null "analyze_report" => visionClient != null
? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient) ? ReportAgentHandler.AnalyzeReport(db, userId, root, visionClient)
: Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }), : Task.FromResult<object>(new { success = false, message = "VLM服务未配置" }),
"manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, db, userId),
"manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root), "manage_archive" => CommonAgentHandler.ExecuteManageArchive(db, userId, root),
"request_doctor" => ConsultationAgentHandler.Execute(toolName, root, db, userId),
_ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" }) _ => Task.FromResult<object>(new { success = false, message = $"未知工具: {toolName}" })
}; };
} }
@@ -470,9 +467,6 @@ public static class AiChatEndpoints
metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm");
} }
break; break;
case "estimate_food_text":
messageType = "diet_analysis";
break;
case "analyze_report": case "analyze_report":
messageType = "report_analysis"; messageType = "report_analysis";
break; break;

View File

@@ -35,7 +35,7 @@ public static class DietEndpoints
MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch, MealType = Enum.TryParse<MealType>(root.TryGetProperty("mealType", out var mt) ? mt.GetString() : "Lunch", out var meal) ? meal : MealType.Lunch,
TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null, TotalCalories = root.TryGetProperty("totalCalories", out var tc) ? tc.GetInt32() : null,
HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null, HealthScore = root.TryGetProperty("healthScore", out var hs) ? hs.GetInt32() : null,
RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.Now), RecordedAt = root.TryGetProperty("recordedAt", out var ra) && DateOnly.TryParse(ra.GetString(), out var d) ? d : DateOnly.FromDateTime(DateTime.UtcNow.AddHours(8)),
}; };
if (root.TryGetProperty("foodItems", out var items)) if (root.TryGetProperty("foodItems", out var items))
{ {

View File

@@ -45,8 +45,8 @@ public static class ExerciseEndpoints
var root = json.RootElement; var root = json.RootElement;
var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动"; var exerciseType = root.TryGetProperty("exerciseType", out var et) ? et.GetString()! : "运动";
var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30; var duration = root.TryGetProperty("durationMinutes", out var dm) ? dm.GetInt32() : 30;
var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.Now.ToString("yyyy-MM-dd"))); var startDate = DateOnly.Parse((root.TryGetProperty("startDate", out var sd) ? sd.GetString()! : DateTime.UtcNow.AddHours(8).ToString("yyyy-MM-dd")));
var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.Now.AddDays(6).ToString("yyyy-MM-dd"))); var endDate = DateOnly.Parse((root.TryGetProperty("endDate", out var ed) ? ed.GetString()! : DateTime.UtcNow.AddHours(8).AddDays(6).ToString("yyyy-MM-dd")));
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate }; var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = startDate };
for (var d = startDate; d <= endDate; d = d.AddDays(1)) for (var d = startDate; d <= endDate; d = d.AddDays(1))
{ {

View File

@@ -12,12 +12,16 @@ public static class MedicationEndpoints
var query = db.Medications.Where(m => m.UserId == userId); var query = db.Medications.Where(m => m.UserId == userId);
if (filter == "active") query = query.Where(m => m.IsActive); if (filter == "active") query = query.Where(m => m.IsActive);
else if (filter == "inactive") query = query.Where(m => !m.IsActive); else if (filter == "inactive") query = query.Where(m => !m.IsActive);
// 北京时间今天对应的 UTC 区间
var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var todayStartUtc = beijingToday.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new var meds = await query.OrderByDescending(m => m.CreatedAt).Select(m => new
{ {
m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(), m.Id, m.Name, m.Dosage, Frequency = m.Frequency.ToString(),
m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes, m.TimeOfDay, m.StartDate, m.EndDate, m.IsActive, m.Notes,
Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt, Source = m.Source.ToString(), m.CreatedAt, m.UpdatedAt,
todayTaken = m.Logs.Any(l => l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken) todayTaken = m.Logs.Any(l => l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken)
}).ToListAsync(ct); }).ToListAsync(ct);
return Results.Ok(new { code = 0, data = meds, message = (string?)null }); return Results.Ok(new { code = 0, data = meds, message = (string?)null });
}); });
@@ -86,8 +90,10 @@ public static class MedicationEndpoints
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var today = DateOnly.FromDateTime(DateTime.UtcNow); var beijingToday = DateTime.UtcNow.AddHours(8).Date;
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt.Date == DateTime.UtcNow.Date && l.Status == MedicationLogStatus.Taken, ct); var todayStartUtc = beijingToday.AddHours(-8);
var todayEndUtc = todayStartUtc.AddDays(1);
var existing = await db.MedicationLogs.FirstOrDefaultAsync(l => l.MedicationId == id && l.CreatedAt >= todayStartUtc && l.CreatedAt < todayEndUtc && l.Status == MedicationLogStatus.Taken, ct);
if (existing != null) { if (existing != null) {
db.MedicationLogs.Remove(existing); db.MedicationLogs.Remove(existing);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);
@@ -96,7 +102,7 @@ public static class MedicationEndpoints
var log = new MedicationLog var log = new MedicationLog
{ {
Id = Guid.NewGuid(), MedicationId = id, UserId = userId, Id = Guid.NewGuid(), MedicationId = id, UserId = userId,
Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.Now), ConfirmedAt = DateTime.UtcNow, Status = MedicationLogStatus.Taken, ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), ConfirmedAt = DateTime.UtcNow,
}; };
db.MedicationLogs.Add(log); db.MedicationLogs.Add(log);
await db.SaveChangesAsync(ct); await db.SaveChangesAsync(ct);

View File

@@ -50,7 +50,11 @@ public static class UserEndpoints
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct); var archive = await db.HealthArchives.FirstOrDefaultAsync(a => a.UserId == userId, ct);
if (archive == null) return Results.Ok(new { code = 40004, message = "档案不存在" }); if (archive == null)
{
archive = new HealthArchive { Id = Guid.NewGuid(), UserId = userId };
db.HealthArchives.Add(archive);
}
archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis; archive.Diagnosis = req.Diagnosis ?? archive.Diagnosis;
archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType; archive.SurgeryType = req.SurgeryType ?? archive.SurgeryType;

View File

@@ -3,6 +3,7 @@ using Health.Infrastructure.AI;
using Health.Infrastructure.Data; using Health.Infrastructure.Data;
using Health.Infrastructure.Services; using Health.Infrastructure.Services;
using Health.WebApi.BackgroundServices; using Health.WebApi.BackgroundServices;
using Health.WebApi.Converters;
using Health.WebApi.Endpoints; using Health.WebApi.Endpoints;
using Health.WebApi.Middleware; using Health.WebApi.Middleware;
using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.JwtBearer;
@@ -27,10 +28,11 @@ if (File.Exists(envPath))
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
// ---- JSON 配置枚举字符串、camelCase---- // ---- JSON 配置枚举字符串、camelCase、UTC时间统一带Z----
builder.Services.ConfigureHttpJsonOptions(options => builder.Services.ConfigureHttpJsonOptions(options =>
{ {
options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter()); options.SerializerOptions.Converters.Add(new System.Text.Json.Serialization.JsonStringEnumConverter());
options.SerializerOptions.Converters.Add(new UtcDateTimeConverter());
options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; options.SerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase;
options.SerializerOptions.PropertyNameCaseInsensitive = true; options.SerializerOptions.PropertyNameCaseInsensitive = true;
}); });

View File

@@ -0,0 +1,24 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Health.WebApi.Converters;
/// <summary>
/// 统一 DateTime 序列化为 ISO 8601 UTC 格式(始终带 Z 后缀),
/// 前端解析时可通过 Z 识别为 UTC 时间并自动转本地时间显示。
/// </summary>
public sealed class UtcDateTimeConverter : JsonConverter<DateTime>
{
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
=> reader.GetDateTime();
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
// 始终按 UTC 写出,带 Z 后缀
writer.WriteStringValue(value.Kind == DateTimeKind.Local
? value.ToUniversalTime()
: value.Kind == DateTimeKind.Unspecified
? DateTime.SpecifyKind(value, DateTimeKind.Utc)
: value);
}
}

438
docs/BUG_REVIEW.md Normal file
View File

@@ -0,0 +1,438 @@
# 健康管家 — 全面代码审查与Bug文档
> 审查日期2026-06-10 | 范围:全栈(后端 .NET 10 + 前端 Flutter
---
## 测试结果汇总
| 测试集 | 通过 | 失败 | 说明 |
|--------|------|------|------|
| 后端单元测试 (entity_tests) | 10 | 0 | 实体/数据库操作全通过 |
| 后端单元测试 (auth_tests) | 4 | 0 | 认证流程全通过 |
| 后端单元测试 (ai_agent_tests - PromptManager) | 8 | 0 | AI提示词全通过 |
| 后端集成测试 (ai_agent_tests - AI对话) | 0 | 5 | 需要后端运行中 |
| Flutter 测试 (widget_test) | - | - | sqlite3 原生库下载失败,无法运行 |
**总计22/27 通过5个集成测试需要后端在线**
---
## 一、后端 Bug
### B1. 缺少 consultation DELETE 端点
- **位置**`consultation_endpoints.cs`
- **问题**:问诊创建后无删除/取消接口。患者无法取消发起的问诊
- **影响**:患者发起错误问诊后无法撤销
- **建议**:添加 `MapDelete("/consultations/{id:guid}", ...)`
### B2. ai_chat_endpoints 中 unified agent 缺少部分工具
- **位置**`ai_chat_endpoints.cs:340-347`
- **问题**unified agent 虽聚合了 7 种工具,但缺少 `manage_archive``request_doctor`
- **影响**:用户在 unified 模式无法通过对话修改档案或请求医生
### B3. CompressImage 未释放 GDI 资源
- **位置**`ai_chat_endpoints.cs:484-503`
- **问题**`Image.FromFile``Bitmap``Graphics``using` 包裹,但 `EncoderParameters` 未释放
- **影响**:每次食物识别可能泄漏少量非托管内存
- **修复**`using var parameters = new EncoderParameters(1);`
### B4. 用药提醒服务可能定时查询过于频繁
- **位置**`BackgroundServices/medication_reminder_service.cs`
- **问题**:需要检查轮询间隔,每分钟查一次可能对数据库压力大
### B5. 开发数据种子硬编码 API Key 检查
- **位置**`dev_data_seeder.cs`
- **问题**`DEVDATA_ENABLED=true` 创建测试用户,生产环境可能误开启
---
## 二、前端 Bug
### C1. ~~聊天列表从顶部跳到底部~~ ✅ 已修复
- **修复**`chat_messages_view.dart` 改为 `reverse:true`
- **修复**`home_page.dart` 滚动逻辑简化
### C2. ~~SwipeDeleteTile 内层手势冲突~~ ✅ 已修复
- **修复**`common_widgets.dart` 滑动状态下用 `AbsorbPointer` 屏蔽内部按钮
### C3. ~~SwipeDeleteTile 红色溢出~~ ✅ 已修复
- **修复**margin 参数从 Stack 内部移到外部 Padding
### C4. ~~运动打卡 dayOfWeek 索引偏差~~ ✅ 已修复
- **修复**`remaining_pages.dart` 改为 `weekday % 7` 与 C# DayOfWeek 对齐
### C5. ~~用药管理 Dismissible 一步删除~~ ✅ 已修复
- **修复**:统一使用 `SwipeDeleteTile`
### C6. Flutter 测试断言错误
- **位置**`health_app/test/widget_test.dart:9`
- **代码**`expect(AppTheme.primary, AppTheme.primaryLight);`
- **问题**`primary` (0xFF6366F1) ≠ `primaryLight` (0xFFEEF2FF),这个断言必然失败
- **修复**:改为 `expect(AppTheme.primary, const Color(0xFF6366F1));`
### C7. home_page.dart onTap 手误写了 `?.()`
- **位置**`home_page.dart:115`
- **代码**`onTap: () => pushRoute(ref, 'notificationPrefs')` 实际没有 `?.()` 问题(之前看错了),此处无误
### C8. 报告详情返回按钮popRoute 在 setState 后
- **位置**`report_pages.dart:449-454`
- **问题**`clearAnalysis()` 调用 `state.copyWith(...)` 然后立即 `popRoute(ref)`,在 pop 过程中可能访问已 dispose 的 provider
- **风险**:中等
### C9. DietCapturePage TextField 内存泄漏
- **位置**`diet_capture_page.dart:465,479,495`
- **问题**:每个食物项创建 `TextEditingController(text: food.name)` 但从不 dispose
- **修复**:用 `TextFormField` + `initialValue` 或缓存 controller
### C10. 报告上传错误吞没
- **位置**`report_pages.dart:199`
- **代码**`} catch (_) {}` — 上传失败无任何反馈
- **修复**:至少显示 snackbar 提示
---
## 三、前端缺失功能
### M1. 饮食记录无编辑/修改功能
- 当前只能查看(`DietRecordDetailPage`)和删除,无法修改已有记录
### M2. 运动计划详情页缺失
- `ExercisePlanPage``onTap: () {}` 为空,点卡片无反应
- 没有类似 `ExercisePlanDetailPage` 的页面
### M3. 问诊列表无前端入口
- 后端有 `/api/consultations` GET但前端没有问诊历史列表页
### M4. 对话无删除/清空功能
- 后端有 `DELETE /api/ai/conversations/{id}`但前端无对应UI
### M5. 无数据导出功能
- 用户无法导出健康数据(血压记录、饮食记录等)
---
## 四、安全与代码质量
### S1. JWT Secret 开发默认值
- **位置**`Program.cs:46`
- **问题**`jwtSecret ??= "dev-secret-key-change-in-production-min-32-chars!!";`
- **风险**:若忘记设环境变量,生产环境用弱密钥
### S2. token 通过 query string 传输SSE
- **位置**`ai_chat_endpoints.cs:25`, `sse_handler.dart:17`
- **问题**`token` 放在 URL query string会被服务器日志、代理缓存
- **风险**:中等(含过期时间的 token但仍有泄露风险
### S3. CORS 全开
- **位置**`Program.cs:96-98`
- **代码**`policy.SetIsOriginAllowed(_ => true)`
- **风险**:生产环境应限定具体 origin
### S4. 全局异常中间件可能泄露内部错误
- **位置**`exception_middleware.cs`
- **需要检查**:是否在生产环境返回了调用栈
### S5. Flutter 硬编码后端 IP
- **位置**`api_client.dart:6`
- **代码**`const String baseUrl = 'http://10.4.164.158:5000';`
- **问题**:每次换网络都需改代码
---
## 五、未使用代码(可清理)
- `chat_messages_view.dart`: `_cardFilledBtn`, `_cardOutlineBtn`, `_agentColors`, `_taskRow` 未使用
- `chat_provider.dart`: `_parseAgent` 未使用
- `remaining_pages.dart`: `shadcn_ui` 导入未使用
- `report_pages.dart`: `shadcn_ui` 导入未使用,`reportId` 变量未使用
- `service_package_detail_page.dart`: `navigation_provider` 导入未使用
---
## 六、Agent 自动审查新增发现
### A1. app_router.dart 无防御 null 断言(🔴严重)
- **位置**`app_router.dart:41-73`
- **代码示例**`ReportDetailPage(id: params['id']!)` 等多处
- **问题**`params['id']!` 若 params 中无 'id' 键,会抛出 null 断言异常导致崩溃
- **影响**:任何路由参数拼写错误或缺少都会 crash
- **修复**:使用 `params['id'] ?? ''` 并提供 fallback
### A2. device_scan_page.dart BLE 流订阅未取消(🔴严重)
- **位置**`device_scan_page.dart`
- **问题**BLE stream subscription 在 dispose 时未 cancel导致蓝牙连接泄漏
- **影响**:多次进出扫描页会积累未释放的 BLE 连接
### A3. 静默吞错误(🟡中)
- **范围**:全前端 28 处 `catch (_) {}`
- **问题**:所有 API 错误、解析错误均无日志或用户提示
- **修复**:至少加上 `debugPrint('Error: $e')` 或显示 snackbar
### A4. 删除后未刷新 Provider🟡中
- **范围**:多个页面
- **问题**:删除操作后调用了 `_load()``_refresh()`setState但未调用 `ref.invalidate(someProvider)`,导致 Riverpod 缓存未更新
- **影响**:切换到其他页面再回来,旧数据可能仍显示
### A5. FutureProvider + setState 双重模式(🟢低)
- **范围**`DietRecordListPage`, `ExercisePlanPage`
- **问题**:同时使用 Riverpod FutureProvider 和本地 setState导致 provider 失效无效
---
## 七、全部问题汇总含Agent发现
| # | 优先级 | 位置 | 问题描述 |
|---|--------|------|----------|
| 1 | 🔴 | `app_router.dart` | null 断言无防御,参数缺失即崩溃 |
| 2 | 🔴 | `device_scan_page.dart` | BLE 流订阅未取消 |
| 3 | 🔴 | `B1` consultation | 缺少 DELETE 端点 |
| 4 | 🔴 | `S1` Program.cs | JWT 开发默认弱密钥 |
| 5 | 🔴 | `C9` diet_capture | TextEditingController 泄漏 |
| 6 | 🟡 | 全局 28处 | catch(_){} 静默吞错 |
| 7 | 🟡 | 多页面 | 删除后无 ref.invalidate |
| 8 | 🟡 | `C6` widget_test | 断言 primary == primaryLight 错误 |
| 9 | 🟡 | `C10` report | 上传失败无提示 |
| 10 | 🟡 | `M2` exercise | 运动详情页缺失onTap 空 |
| 11 | 🟡 | `C8` report | pop 时序问题 |
| 12 | 🟡 | `A5` 多页面 | FutureProvider+setState 双重模式 |
| 13 | 🟢 | `B3` ai_chat | EncoderParameters 未释放 |
| 14 | 🟢 | `B4` bg_service | 用药提醒轮询频率需审视 |
| 15 | 🟢 | `B5` dev_data | 生产环境可能误开启测试数据 |
| 16 | 🟢 | `S2` SSE | token 走 query string |
| 17 | 🟢 | `S3` CORS | 全开 AllowCredentials |
| 18 | 🟢 | `S5` api_client | 硬编码 IP |
| 19 | 🟢 | 多处 | 未使用代码可清理 |
| 20 | 🟢 | `B2` unified | unified agent 缺少 manage_archive 工具 |
---
## 八、后端 Agent 审查新增发现(关键)
### D1. 运动计划 DayOfWeek 跨层不一致(🔴严重)
- **位置**`prompt_manager.cs` vs `exercise_plan.cs` 实体注释
- **问题**Prompt 告诉 AI `day_of_week: 0-6(周日=0)`,但实体注释 `// 0=周一, 6=周日`。AI 按周日=0 生成数据,后端按周一=0 解析,运动计划星期全偏一天
- **修复**:统一为 C# DayOfWeek 枚举0=周日)
### D2. SMS 验证码使用伪随机数(🔴安全漏洞)
- **位置**`sms_service.cs`
- **问题**`Random.Shared.Next(100000, 1000000)` 使用 PRNG攻击者可预测验证码
- **修复**:改用 `RandomNumberGenerator.GetInt32(100000, 999999)`
### D3. checkin 无所有权验证(🔴越权漏洞)
- **位置**`medication_agent_handler.cs:99``exercise_agent_handler.cs:69`
- **问题**`confirm_medication``exercise checkin` 直接通过 itemId 操作,不验证是否属于当前用户。任何认证用户可操作他人数据
- **修复**:添加 `&& item.Plan.UserId == userId` 检查
### D4. VisionAsync content 序列化错误(🔴严重)
- **位置**`open_ai_compatible_client.cs:136`
- **问题**:将图片 contentParts 先序列化为 JSON 字符串再赋值给 Content但 OpenAI 兼容 API 期望 Content 为数组格式
- **影响**:食物识别 VLM 调用可能失败
### D5. diet/consultation agent 工具声明但未实现(🔴严重)
- **位置**`diet_agent_handler.cs``consultation_agent_handler.cs`
- **问题**`EstimateFoodTool``RequestDoctorTool` 在 Tools 列表中声明,但 Execute 方法无对应 case调用返回"未知工具"
- **影响**:饮食识别和请求医生功能不可用
### D6. CleanupService 删除未级联(🔴严重)
- **位置**`cleanup_service.cs:28`
- **问题**`db.Conversations.RemoveRange(oldConversations)` 未先删除关联的 ConversationMessage可能因 FK 约束抛异常
- **修复**:先删除 Messages 再删 Conversations或使用 ExecuteDeleteAsync
### D7. 用药提醒时区计算 bug🔴严重
- **位置**`medication_reminder_service.cs:37`
- **问题**`DateTime.SpecifyKind(beijingNow.Date, DateTimeKind.Utc)` — 北京时间 00:00 被标记为 UTC 00:00导致打卡检测有 8 小时偏差
- **影响**:提醒时间错位、重复提醒或漏提醒
### D8. DbContext 未配置外键和级联删除(🟡中等)
- **位置**`app_db_context.cs`
- **问题**`OnModelCreating` 没有任何 `HasOne/WithMany/HasForeignKey/OnDelete` 配置,全凭约定
- **影响**:数据库无 FK 约束、级联行为不明确、RefreshToken 全表扫描
### D9. 缺少多个数据库索引(🟡中等)
- RefreshToken: 无索引,认证查询全表扫描
- FollowUp: 无 (UserId, ScheduledAt) 索引
- DeviceToken: 无 UserId 索引
- Report: 无 (UserId, CreatedAt) 索引
### D10. ExceptionMiddleware 统一返回500🟡中等
- **位置**`exception_middleware.cs`
- **问题**:所有异常都返回 500不区分 400/401/404
- **影响**:客户端无法根据状态码处理不同类型的错误
### D11. 用药提醒未实际推送(🟡中等)
- **位置**`medication_reminder_service.cs`
- **问题**TODO 注释表明推送尚未实现,只记录日志
### D12. Prompt 文本硬编码(🟢低)
- 不支持热更新,修改需重新编译
---
## 九、最终汇总(所有发现)
| # | 优先级 | 类别 | 位置 | 问题 |
|---|--------|------|------|------|
| 1 | 🔴 | 安全 | sms_service | 验证码用 PRNG 可预测 |
| 2 | 🔴 | 安全 | agent handlers | checkin 越权漏洞 |
| 3 | 🔴 | 逻辑 | prompt/entity | DayOfWeek 跨层不一致 |
| 4 | 🔴 | 功能 | diet/consult agent | 工具声明但未实现 |
| 5 | 🔴 | 功能 | open_ai_client | Vision content 序列化错误 |
| 6 | 🔴 | 稳定性 | cleanup_service | 删除未级联 FK 冲突 |
| 7 | 🔴 | 功能 | reminder_service | 时区计算 8h 偏差 |
| 8 | 🔴 | 崩溃 | app_router.dart | null 断言无防御 |
| 9 | 🔴 | 泄漏 | device_scan_page | BLE 流未取消 |
| 10 | 🔴 | 安全 | Program.cs | CORS 全开+AllowCredentials |
| 11 | 🔴 | 安全 | Program.cs | JWT 默认弱密钥 |
| 12 | 🔴 | 泄漏 | diet_capture | TextEditingController 未释放 |
| 13 | 🟡 | 体验 | 28处 | catch(_){} 静默吞错 |
| 14 | 🟡 | 逻辑 | 多页面 | 删除后未 invalidate provider |
| 15 | 🟡 | 配置 | app_db_context | 无 FK/级联/索引 |
| 16 | 🟡 | 体验 | exception_middleware | 统一返回 500 |
| 17 | 🟡 | 测试 | widget_test | 断言永远失败 |
| 18 | 🟡 | 功能 | 用药提醒 | 推送未实现 |
| 19 | 🟡 | 架构 | 多页面 | FutureProvider+setState 混用 |
| 20 | 🟡 | 缺失 | exercise | 计划详情页缺失 |
| 21 | 🟢 | 代码 | 多处 | 未使用代码/本地时间/冗余 |
---
## 十、后端端点 Agent 审查新增发现(关键)
### E1. 医生端点零授权(🔴阻断级)
- **位置**`doctor_endpoints.cs:19`
- **问题**`MapGroup("/api/doctor")` **没有 `.RequireAuthorization()`**,所有医生端点(患者详情、健康数据、问诊、报告、随访)对公网开放
- **影响**:任何人可查看所有患者隐私数据、修改报告、创建/删除随访
### E2. consultation POST 消息无所有权检查(🔴阻断级)
- **位置**`consultation_endpoints.cs:48-75`
- **问题**:发消息只检查 consultation 存在不验证是否属于当前用户。用户A可向用户B的问诊发消息
### E3. exercise checkin 无所有权检查(🔴阻断级)
- **位置**`exercise_endpoints.cs:119`
- **问题**`FindAsync([itemId])` 只按ID查不验证 `item.Plan.UserId == userId`。用户可操作他人运动计划
### E4. SMS验证码在响应中暴露🔴严重
- **位置**`auth_endpoints.cs:34`
- **问题**`devCode = code` 将6位验证码直接返回在JSON中`#if DEBUG` 守卫
### E5. Task.Run 火后不理模式(🔴严重)
- **位置**`report_endpoints.cs:67`
- **问题**`_ = Task.Run(async () => { ... }, CancellationToken.None)` 在请求结束后 scope 可能已释放,后台任务崩溃
### E6. System.Drawing 仅Windows🔴严重
- **位置**`ai_chat_endpoints.cs:486-494`
- **问题**`Image.FromFile`/`Bitmap`/`Graphics` 在Linux容器中崩溃
### E7. 健康数据 N+1 查询(🟡中等)
- **位置**`health_endpoints.cs:78-89`
- **问题**`/latest` 对5种指标类型分别发一次SQL查询
### E8. 日历用药事件显示错误(🟡中等)
- **位置**`calendar_endpoints.cs:49`
- **问题**:用户有任意活跃用药就在每月每天标记"用药",不区分具体哪天该吃药
### E9. 手动JSON解析绕过模型绑定🟡中等
- **范围**diet、medication、exercise、doctor endpoints
- **问题**`JsonDocument.Parse` 手动解析导致 Swagger 无法文档化、无自动验证、拼写错误抛500
### E10. 缺失端点
- health缺 DELETE
- diet缺 PUT
- exercise缺 PUT
- report缺 DELETE
- file缺 GET/DELETE/list
- followup缺 detail/confirm
---
## 十一、完整问题排名
| # | 等级 | 文件 | 问题 |
|---|------|------|------|
| 1 | 🔴🔴 | doctor_endpoints | **零授权** — 所有患者数据公开 |
| 2 | 🔴🔴 | consultation | 发消息无所有权检查 |
| 3 | 🔴🔴 | exercise | checkin无所有权检查 |
| 4 | 🔴 | auth | SMS验证码响应暴露 |
| 5 | 🔴 | report | Task.Run火后不理 |
| 6 | 🔴 | ai_chat | System.Drawing仅Windows |
| 7 | 🔴 | sms_service | PRNG可预测验证码 |
| 8 | 🔴 | agent handlers | checkin越权 |
| 9 | 🔴 | prompt_manager | DayOfWeek不一致 |
| 10 | 🔴 | diet/consult agent | 工具声明未实现 |
| 11 | 🔴 | open_ai_client | Vision序列化错误 |
| 12 | 🔴 | cleanup_service | 删除未级联 |
| 13 | 🔴 | reminder_service | 时区8h偏差 |
| 14 | 🔴 | app_router | null断言崩溃 |
| 15 | 🔴 | device_scan | BLE泄漏 |
| 16 | 🔴 | Program.cs | CORS全开 |
| 17 | 🔴 | Program.cs | JWT弱密钥 |
| 18 | 🔴 | diet_capture | Controller泄漏 |
| 19 | 🟡 | 28处 | catch(_){}吞错 |
| 20 | 🟡 | 多页面 | 删除未invalidate |
| 21 | 🟡 | health | N+1查询 |
| 22 | 🟡 | calendar | 用药事件逻辑错误 |
| 23 | 🟡 | app_db_context | 无FK/索引配置 |
| 24 | 🟡 | exception_mw | 统一500 |
| 25 | 🟡 | 提醒服务 | 推送未实现 |
| 26 | 🟡 | 多端点 | 缺PUT/DELETE |
| 27 | 🟡 | 多端点 | 手动JSON无验证 |
---
## 优先级排序
| 优先级 | Bug | 影响范围 |
|--------|-----|----------|
| 🔴 高 | C9: TextEditingController 内存泄漏 | 饮食识别页面 |
| 🔴 高 | B1: 缺少 consultation DELETE | 问诊功能 |
| 🔴 高 | S1: JWT 弱密钥默认值 | 安全 |
| 🟡 中 | C6: Flutter 测试断言错误 | CI/CD |
| 🟡 中 | C10: 报告上传错误吞没 | 用户体验 |
| 🟡 中 | M2: 运动计划详情页缺失 | 用户体验 |
| 🟡 中 | C8: report pop 时序问题 | 潜在崩溃 |
| 🟢 低 | B3: EncoderParameters 未释放 | 极少量泄漏 |
| 🟢 低 | S5: 硬编码 IP | 开发体验 |
| 🟢 低 | 未使用代码 | 代码质量 |
---
*共发现 15 个问题5 个缺失功能。其中 5 个 Bug 已在本轮修复。*
---
## 第二轮换角度审查(新增 10 个问题)
### F1. uploadFile 响应解析崩溃 🔴
`api_client.dart:60-69` — 后端返回 `data: [{id, name, size}]`List前端做 `data['data']?['url']` 对List用字符串索引运行时异常。聊天图片上传全部无声失败。
### F2. consultationChatProvider SignalR 永停不了 🔴
`consultation_provider.dart``stop()` 定义了但无 Widget dispose 调用。离开问诊页后 SignalR 连接和 5秒轮询永续运行。
### F3. chatProvider 流订阅无 dispose 🔴
`chat_provider.dart``_subscription`/`_streamTimer` 无 disposeprovider 销毁即泄漏。
### F4. ChatMessage 原地修改破坏不可变性 🔴
`msg.confirmed = true` 直接修改 state 中的对象 → 违反 Riverpod 不可变契约。
### F5. 所有 FutureProvider 无 autoDispose 🟡
6 个 FutureProvider 缓存永不过期,页面级数据不释放。
### F6. authProvider Token 刷新窗口期 id/phone 为空 🟡
`auth_provider.dart:59``UserInfo(id: '', phone: '')` 然后等 `_loadProfile()` 异步补全。
### F7. 医生 Web 零认证 🔴🔴
`doctor_web/src/services/api-client.ts` — 不发送 Authorization 头。叠加后端 doctor_endpoints 零授权E1任何人可访问所有患者数据。
### F8. 医生 Web SignalR handler 泄漏 🔴
`ChatPage.tsx:28-65` — 组件卸载时若在 Reconnecting 状态,跳过 `off('ReceiveMessage')`,重复挂载累积 handler。
### F9. 6个后端端点前端从未调用 🟢
`GET/DELETE /api/ai/conversations``GET /api/consultations``PUT /api/health-records/{id}`、全部 `/api/doctor/*`
### F10. 37 对 API 契约中 1 对崩溃 + 多端手动JSON脆弱 🟡
`uploadFile` 是唯一崩溃的。其余 36 对匹配但 `TryGetProperty` 区分大小写,依赖前端恰好用 camelCase。
---
*两轮共发现 37 个问题,其中 5 个阻断级、19 个严重、10 个中等、3 个低。*

View File

@@ -104,7 +104,7 @@ class _AuthInterceptor extends Interceptor {
final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts); final retryResponse = await Dio(BaseOptions(baseUrl: baseUrl)).fetch(opts);
return handler.resolve(retryResponse); return handler.resolve(retryResponse);
} }
} catch (_) {} } catch (e) { print('[ApiClient] token刷新失败: $e'); }
} }
await _client.clearTokens(); await _client.clearTokens();
} }

View File

@@ -1,197 +1,147 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
/// 统一配色方案 - 蚂蚁阿福风格:紫蓝渐变 + 淡紫背景 + 圆角卡片 /// 健康管家 — 蚂蚁阿福风格 v2.0
/// 柔和淡紫 + 大量留白 + 紫色仅作点缀
class AppColors { class AppColors {
AppColors._(); AppColors._();
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 主色调 - 紫蓝渐变系 // 主色 — 柔和淡紫(非浓烈深紫)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 主紫色(鲜艳紫蓝,阿福风主按钮色 static const Color primary = Color(0xFF8B5CF6); // 主紫(柔和不刺眼
static const Color primary = Color(0xFF6366F1); static const Color primaryLight = Color(0xFFA78BFA); // 浅紫(装饰/渐变)
static const Color primaryDark = Color(0xFF7C3AED); // 深紫(按压态)
static const Color primaryMid = Color(0xFFA78BFA); // 兼容旧名
/// 主紫色深色 // 渐变 — 柔和淡紫,仅垂直
static const Color primaryDark = Color(0xFF4F46E5); static const LinearGradient primaryGradient = LinearGradient(
begin: Alignment.topCenter,
/// 浅紫(用于背景、图标底) end: Alignment.bottomCenter,
static const Color primaryLight = Color(0xFFEEF2FF); colors: [Color(0xFF8B5CF6), Color(0xFFA78BFA)],
);
/// 中紫(渐变过渡)
static const Color primaryMid = Color(0xFFA78BFA);
/// 青色点缀(渐变右侧,标签色)
static const Color accentCyan = Color(0xFF22D3EE);
/// 青色深色
static const Color accentCyanDark = Color(0xFF06B6D4);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 背景色 // 背景色
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 页面背景(淡紫蓝,营造漂浮感 static const Color background = Color(0xFFF0ECFF); // 页面全局背景(淡紫)
static const Color background = Color(0xFFF8FAFF); static const Color backgroundSoft = Color(0xFFF0ECFF); // 输入框底色(淡紫,同页面背景)
static const Color cardBackground = Color(0xFFFFFFFF); // 卡片白色
static const Color cardInner = Color(0xFFF5F2FF); // 卡片内区域底色
/// 更淡的紫背景(最底层,带渐变感 // 主背景渐变紫→蓝4:6比例
static const Color backgroundSoft = Color(0xFFF5F3FF); static const LinearGradient bgGradient = LinearGradient(
begin: Alignment.centerLeft,
end: Alignment.centerRight,
colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)],
stops: [0.0, 0.4],
);
/// 卡片背景 - 纯白 // 导航栏背景(极淡蓝紫)
static const Color cardBackground = Color(0xFFFFFFFF); static const LinearGradient navGradient = LinearGradient(
begin: Alignment.topCenter,
/// 卡片内小卡片背景(极淡紫灰) end: Alignment.bottomCenter,
static const Color cardInner = Color(0xFFF8FAFF); colors: [Color(0xFFE0E7FF), Color(0xFFC7D2FE)],
);
// ═══════════════════════════════════════════════════════════
// 图标渐变配色
// ═══════════════════════════════════════════════════════════
/// 报告/数据图标:蓝紫渐变
static const Color iconBlueStart = Color(0xFF818CF8);
static const Color iconBlueEnd = Color(0xFF6366F1);
/// 药管家图标:青蓝渐变
static const Color iconCyanStart = Color(0xFF67E8F9);
static const Color iconCyanEnd = Color(0xFF22D3EE);
/// 拍皮肤图标:紫蓝渐变
static const Color iconPurpleStart = Color(0xFFA78BFA);
static const Color iconPurpleEnd = Color(0xFF7C3AED);
/// 通用图标底色
static const Color iconBg = Color(0xFFEEF2FF);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 文字颜色 // 文字颜色
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 主要文字(深紫黑) static const Color textPrimary = Color(0xFF1F2937);
static const Color textPrimary = Color(0xFF1E1B4B); static const Color textSecondary = Color(0xFF6B7280);
static const Color textHint = Color(0xFF9CA3AF);
/// 次要文字(深灰)
static const Color textSecondary = Color(0xFF64748B);
/// 辅助文字(浅灰)
static const Color textHint = Color(0xFF94A3B8);
/// 渐变上的白色文字
static const Color textOnGradient = Color(0xFFFFFFFF); static const Color textOnGradient = Color(0xFFFFFFFF);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 边框分割线 // 边框/分割线
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 默认边框色(极淡) static const Color border = Color(0xFFE5E7EB);
static const Color border = Color(0xFFE2E8F0); static const Color borderLight = Color(0xFFF3F4F6);
/// 更浅边框色
static const Color borderLight = Color(0xFFF1F5F9);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 功能色 // 功能色
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 成功色(柔和绿) static const Color success = Color(0xFF10B981);
static const Color success = Color(0xFF22C55E); static const Color successLight = Color(0xFFD1FAE5);
/// 成功浅色
static const Color successLight = Color(0xFFDCFCE7);
/// 错误色(柔和红)
static const Color error = Color(0xFFEF4444); static const Color error = Color(0xFFEF4444);
/// 错误浅色
static const Color errorLight = Color(0xFFFEE2E2); static const Color errorLight = Color(0xFFFEE2E2);
/// 警告色(橙)
static const Color warning = Color(0xFFF59E0B); static const Color warning = Color(0xFFF59E0B);
/// 警告浅色
static const Color warningLight = Color(0xFFFEF3C7); static const Color warningLight = Color(0xFFFEF3C7);
static const Color blueMeasure = Color(0xFF3B82F6);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 渐变组 // 图标 / 小区域底色(极淡紫蓝)
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 主渐变:紫 → 蓝紫(主按钮填充) static const Color iconBg = Color(0xFFE8E0FA); // 卡片内底色,比页面背景稍深
static const LinearGradient primaryGradient = LinearGradient(
colors: [Color(0xFF818CF8), Color(0xFF6366F1)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// 边框渐变:蓝紫 → 紫 → 青(按钮/卡片描边) // 兼容旧名字
static const LinearGradient borderGradient = LinearGradient( static const Color iconBlueStart = Color(0xFF8B5CF6);
colors: [Color(0xFF818CF8), Color(0xFFA78BFA), Color(0xFF22D3EE)], static const Color iconBlueEnd = Color(0xFFA78BFA);
begin: Alignment.centerLeft, static const Color iconCyanStart = Color(0xFF10B981);
end: Alignment.centerRight, static const Color iconCyanEnd = Color(0xFF34D399);
); static const Color iconPurpleStart = Color(0xFF8B5CF6);
static const Color iconPurpleEnd = Color(0xFFA78BFA);
/// 青色渐变(药管家等) static const Color accentCyan = Color(0xFF10B981);
static const LinearGradient cyanGradient = LinearGradient( static const Color accentCyanDark = Color(0xFF059669);
colors: [Color(0xFF67E8F9), Color(0xFF22D3EE)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// 紫色渐变(拍皮肤等)
static const LinearGradient purpleGradient = LinearGradient(
colors: [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
/// 页面背景渐变(淡紫 → 淡蓝,营造漂浮感)
static const LinearGradient pageBgGradient = LinearGradient(
colors: [Color(0xFFF5F3FF), Color(0xFFEFF6FF)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
);
/// 卡片内轻渐变
static const LinearGradient cardLightGradient = LinearGradient(
colors: [Color(0xFFEEF2FF), Color(0xFFF0F9FF)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
);
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
// 阴影 // 渐变 — 所有旧名字统一为柔和淡紫
// ═══════════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════════
/// 卡片阴影(淡紫色柔和阴影) static LinearGradient get borderGradient => primaryGradient;
static List<BoxShadow> cardShadow = [ static LinearGradient get cyanGradient => primaryGradient;
BoxShadow( static LinearGradient get purpleGradient => primaryGradient;
color: Color(0xFF6366F1).withOpacity(0.08), static LinearGradient get pageBgGradient => bgGradient;
blurRadius: 24, static LinearGradient get cardLightGradient => bgGradient;
offset: const Offset(0, 8), static LinearGradient get gWelcomeHeader => primaryGradient;
),
// 各种旧花渐变全改为极淡色或纯色
static LinearGradient get gPurplePink => primaryGradient;
static LinearGradient get gBluePurple => primaryGradient;
static LinearGradient get gPinkPurple => primaryGradient;
static LinearGradient get gBlueRed => primaryGradient;
static LinearGradient get gBlueGreen => primaryGradient;
static LinearGradient get gPurpleCyan => primaryGradient;
static LinearGradient get gRedPurple => primaryGradient;
static LinearGradient get gCyanBlue => primaryGradient;
static LinearGradient get gPinkCyan => primaryGradient;
static LinearGradient get gLightPurple => bgGradient;
static LinearGradient get gLightBlue => bgGradient;
// ═══════════════════════════════════════════════════════════
// 阴影 — 轻柔
// ═══════════════════════════════════════════════════════════
static List<BoxShadow> get cardShadow => [
BoxShadow(color: Colors.black.withOpacity(0.06), blurRadius: 8, offset: const Offset(0, 2)),
];
static List<BoxShadow> get cardShadowLight => [
BoxShadow(color: Colors.black.withOpacity(0.04), blurRadius: 6, offset: const Offset(0, 1)),
];
static List<BoxShadow> get buttonShadow => [
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.25), blurRadius: 12, offset: const Offset(0, 4)),
];
static List<BoxShadow> get fabShadow => [
BoxShadow(color: Color(0xFF8B5CF6).withOpacity(0.30), blurRadius: 16, offset: const Offset(0, 6)),
]; ];
/// 轻卡片阴影 // ═══════════════════════════════════════════════════════════
static List<BoxShadow> cardShadowLight = [ // 旧代码兼容别名chat_messages_view 原始代码引用)
BoxShadow( // ═══════════════════════════════════════════════════════════
color: Color(0xFF6366F1).withOpacity(0.05),
blurRadius: 16,
offset: const Offset(0, 4),
),
];
/// 按钮阴影 static const Color purpleGradientStart = Color(0xFF8B5CF6);
static List<BoxShadow> buttonShadow = [ static const Color blueGradientEnd = Color(0xFF3B82F6);
BoxShadow( static LinearGradient get purpleBlueGradient => primaryGradient;
color: Color(0xFF6366F1).withOpacity(0.25), static Color get primaryPurple => primary;
blurRadius: 16, static Color get iconColor => primary;
offset: const Offset(0, 6), static Color get iconBackground => iconBg;
), static Color get primaryBlue => blueMeasure;
]; static LinearGradient get lightGradient => gLightPurple;
static Color get backgroundSecondary => background;
/// 悬浮操作按钮阴影(右下角大号按钮) static LinearGradient get successButtonGradient => primaryGradient;
static List<BoxShadow> fabShadow = [
BoxShadow(
color: Color(0xFF6366F1).withOpacity(0.35),
blurRadius: 20,
offset: const Offset(0, 8),
),
];
} }

View File

@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import 'app_colors.dart'; import 'app_colors.dart';
/// 健康管家 —— 蚂蚁阿福风格:紫蓝渐变 + 超大圆角 + 漂浮卡片 /// 健康管家 — 设计规范 v1.0:紫色系 + 干净白灰 + 12px圆角
class AppTheme { class AppTheme {
AppTheme._(); AppTheme._();
@@ -11,8 +11,8 @@ class AppTheme {
static const Color primaryLight = AppColors.primaryLight; static const Color primaryLight = AppColors.primaryLight;
static const Color primaryDark = AppColors.primaryDark; static const Color primaryDark = AppColors.primaryDark;
static const Color primaryMid = AppColors.primaryMid; static const Color primaryMid = AppColors.primaryMid;
static const Color accent = AppColors.accentCyan; static const Color accent = AppColors.success;
static const Color info = AppColors.accentCyan; static const Color info = AppColors.blueMeasure;
// ── 中性色 ── // ── 中性色 ──
static const Color bg = AppColors.background; static const Color bg = AppColors.background;
@@ -33,51 +33,51 @@ class AppTheme {
static const Color warning = AppColors.warning; static const Color warning = AppColors.warning;
static const Color warningLight = AppColors.warningLight; static const Color warningLight = AppColors.warningLight;
// ── 圆角(阿福风格:超大圆角)── // ── 圆角(规范12px 卡片)──
static const double rXs = 8; static const double rXs = 4;
static const double rSm = 12; static const double rSm = 8;
static const double rMd = 18; static const double rMd = 12;
static const double rLg = 24; static const double rLg = 16;
static const double rXl = 32; static const double rXl = 20;
static const double rPill = 999; static const double rPill = 999;
// ── 间距 ── // ── 间距8px 网格)──
static const double sXs = 4; static const double sXs = 4;
static const double sSm = 8; static const double sSm = 8;
static const double sMd = 12; static const double sMd = 12;
static const double sLg = 16; static const double sLg = 16;
static const double sXl = 20; static const double sXl = 20;
static const double sXxl = 28; static const double sXxl = 24;
// ── 阴影(淡紫柔和阴影)── // ── 阴影(阴影)──
static BoxShadow get shadowCard => BoxShadow( static BoxShadow get shadowCard => BoxShadow(
color: AppColors.primary.withOpacity(0.08), color: Colors.black.withOpacity(0.06),
blurRadius: 24, blurRadius: 8,
offset: const Offset(0, 8), offset: const Offset(0, 2),
); );
static BoxShadow get shadowLight => BoxShadow( static BoxShadow get shadowLight => BoxShadow(
color: AppColors.primary.withOpacity(0.05), color: Colors.black.withOpacity(0.04),
blurRadius: 16, blurRadius: 6,
offset: const Offset(0, 4), offset: const Offset(0, 1),
); );
static BoxShadow get shadowElevated => BoxShadow( static BoxShadow get shadowElevated => BoxShadow(
color: AppColors.primary.withOpacity(0.15), color: primary.withOpacity(0.08),
blurRadius: 32, blurRadius: 12,
offset: const Offset(0, 10), offset: const Offset(0, 4),
); );
static BoxShadow get shadowButton => BoxShadow( static BoxShadow get shadowButton => BoxShadow(
color: AppColors.primary.withOpacity(0.25), color: primary.withOpacity(0.25),
blurRadius: 16, blurRadius: 12,
offset: const Offset(0, 6), offset: const Offset(0, 4),
); );
// ── 智能体配色 ── // ── 智能体配色 ──
static const Map<String, Color> agentColors = { static const Map<String, Color> agentColors = {
'default': AppColors.primaryLight, 'default': Color(0xFFEDE9FE),
'consultation': Color(0xFFE0E7FF), 'consultation': Color(0xFFE0E7FF),
'health': Color(0xFFDCFCE7), 'health': Color(0xFFD1FAE5),
'diet': Color(0xFFFEF3C7), 'diet': Color(0xFFFEF3C7),
'medication': Color(0xFFFCE7F3), 'medication': Color(0xFFFEE2E2),
'report': Color(0xFFDBEAFE), 'report': Color(0xFFDBEAFE),
'exercise': Color(0xFFEDE9FE), 'exercise': Color(0xFFEDE9FE),
}; };
@@ -94,70 +94,75 @@ class AppTheme {
onPrimary: Colors.white, onPrimary: Colors.white,
secondary: accent, secondary: accent,
surface: surface, surface: surface,
background: bg,
brightness: Brightness.light, brightness: Brightness.light,
), ),
scaffoldBackgroundColor: bg, scaffoldBackgroundColor: bg,
splashColor: Colors.transparent, splashColor: Colors.transparent,
highlightColor: Colors.transparent, highlightColor: Colors.transparent,
hoverColor: primaryLight.withOpacity(0.5), hoverColor: primaryLight.withOpacity(0.3),
fontFamily: null, fontFamily: null,
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: bg, backgroundColor: surface,
foregroundColor: text, foregroundColor: text,
elevation: 0, elevation: 0,
centerTitle: false, centerTitle: true,
scrolledUnderElevation: 0, scrolledUnderElevation: 0,
titleTextStyle: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), titleTextStyle: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
toolbarHeight: 44,
), ),
cardTheme: CardThemeData( cardTheme: CardThemeData(
color: surface, color: surface,
elevation: 0, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
margin: EdgeInsets.zero, margin: EdgeInsets.zero,
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
filled: true, filled: true,
fillColor: bgSoft, fillColor: bgSoft,
contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 16), contentPadding: const EdgeInsets.symmetric(horizontal: sLg, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), border: OutlineInputBorder(
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(rMd), borderSide: BorderSide.none), borderRadius: BorderRadius.circular(10),
focusedBorder: OutlineInputBorder( borderSide: BorderSide.none,
borderRadius: BorderRadius.circular(rMd),
borderSide: const BorderSide(color: primary, width: 2),
), ),
hintStyle: TextStyle(color: textHint, fontSize: 16), enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: primary, width: 1),
),
hintStyle: TextStyle(color: textHint, fontSize: 15),
), ),
elevatedButtonTheme: ElevatedButtonThemeData( elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: primary, backgroundColor: primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 56), minimumSize: const Size(double.infinity, 52),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rPill)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rMd)),
textStyle: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600), textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
elevation: 0, elevation: 0,
shadowColor: primary.withOpacity(0.3),
), ),
), ),
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rXl))), dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(rLg))),
textTheme: const TextTheme( textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: text, letterSpacing: -0.3), headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
titleLarge: TextStyle(fontSize: 22, fontWeight: FontWeight.w700, color: text), titleLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
titleMedium: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text), titleMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: text),
bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5), bodyLarge: TextStyle(fontSize: 15, color: text, height: 1.5),
bodyMedium: TextStyle(fontSize: 14, color: textSub, height: 1.4), bodyMedium: TextStyle(fontSize: 13, color: textSub, height: 1.4),
labelMedium: TextStyle(fontSize: 13, color: textSub), labelMedium: TextStyle(fontSize: 13, color: textSub),
labelSmall: TextStyle(fontSize: 12, color: textHint), labelSmall: TextStyle(fontSize: 12, color: textHint),
), ),
); );
// ── Shadcn UI 主题(紫蓝系) ── // ── Shadcn UI 主题 ──
static ShadThemeData get shadTheme => ShadThemeData( static ShadThemeData get shadTheme => ShadThemeData(
brightness: Brightness.light, brightness: Brightness.light,
colorScheme: ShadVioletColorScheme.light( colorScheme: ShadVioletColorScheme.light(

View File

@@ -1,8 +1,15 @@
import 'package:flutter/foundation.dart' show kIsWeb;
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 'package:sqflite_common_ffi_web/sqflite_ffi_web.dart'
show databaseFactoryFfiWebNoWebWorker;
import 'package:sqflite/sqflite.dart' show databaseFactory;
import 'app.dart'; import 'app.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
if (kIsWeb) {
databaseFactory = databaseFactoryFfiWebNoWebWorker;
}
runApp(const ProviderScope(child: HealthApp())); runApp(const ProviderScope(child: HealthApp()));
} }

View File

@@ -1,9 +1,11 @@
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 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.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 '../../providers/data_providers.dart';
class LoginPage extends ConsumerStatefulWidget { class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key}); const LoginPage({super.key});
@@ -46,12 +48,15 @@ class _LoginPageState extends ConsumerState<LoginPage> {
final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
setState(() => _loading = false); setState(() => _loading = false);
if (err != null) { setState(() => _error = err); return; } if (err != null) { setState(() => _error = err); return; }
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(currentExercisePlanProvider);
goRoute(ref, 'home'); goRoute(ref, 'home');
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
final authState = ref.watch(authProvider); final authState = ref.watch(authProvider);
final theme = ShadTheme.of(context);
if (authState.isLoggedIn && !authState.isLoading) { if (authState.isLoggedIn && !authState.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home')); WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
} }
@@ -60,189 +65,149 @@ class _LoginPageState extends ConsumerState<LoginPage> {
body: Container( body: Container(
decoration: const BoxDecoration( decoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topCenter,
end: Alignment.bottomRight, end: Alignment.bottomCenter,
colors: [AppTheme.bg, AppTheme.bg, AppTheme.primaryLight], colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
), ),
), ),
child: SafeArea(child: Center(child: SingleChildScrollView( child: SafeArea(child: Center(child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 32), padding: const EdgeInsets.symmetric(horizontal: 32),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
const SizedBox(height: 24), const SizedBox(height: 40),
// Logo // Logo
Container( Container(
width: 100, height: 100, width: 88, height: 88,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primary.withAlpha(25), color: Colors.white,
borderRadius: BorderRadius.circular(50), borderRadius: BorderRadius.circular(44),
boxShadow: [BoxShadow(color: AppColors.primary.withOpacity(0.15), blurRadius: 20, offset: const Offset(0, 8))],
), ),
child: Stack(alignment: Alignment.center, children: [ child: const Icon(LucideIcons.heart, size: 40, color: AppColors.primary),
Container(
width: 72, height: 72,
decoration: BoxDecoration(
color: Colors.white.withAlpha(200),
borderRadius: BorderRadius.circular(36),
),
child: const Icon(LucideIcons.heart, size: 36, color: AppTheme.primary),
),
]),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
Text('健康管家', style: TextStyle(fontSize: 32, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)), const Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: 8), const SizedBox(height: 6),
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground)), const Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: AppColors.textSecondary)),
const SizedBox(height: 36), const SizedBox(height: 40),
// 手机号输入 // 手机号
_buildPhoneInput(theme), Container(
const SizedBox(height: AppTheme.sLg), height: 52,
// 验证码输入
_buildCodeRow(theme),
const SizedBox(height: AppTheme.sSm),
// 协议勾选
_buildAgreement(theme),
// 错误提示
if (_error != null)
Padding(padding: const EdgeInsets.only(top: AppTheme.sMd), child: Text(_error!, style: TextStyle(color: theme.colorScheme.destructive, fontSize: 16))),
const SizedBox(height: AppTheme.rXl),
// 登录按钮
_buildLoginButton(theme),
const SizedBox(height: AppTheme.sXl),
]),
))),
),
);
}
Widget _buildPhoneInput(ShadThemeData theme) {
return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.card, color: AppColors.backgroundSoft,
borderRadius: BorderRadius.circular(AppTheme.rSm), borderRadius: BorderRadius.circular(10),
boxShadow: [AppTheme.shadowLight],
), ),
child: Row(children: [ child: Row(children: [
const Padding(padding: EdgeInsets.only(left: AppTheme.sLg), child: Text('+86', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w500, color: AppTheme.text))), const Padding(padding: EdgeInsets.only(left: 16), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary))),
Container(width: 1, height: 24, color: theme.colorScheme.border, margin: const EdgeInsets.symmetric(horizontal: AppTheme.sMd)), Container(width: 1, height: 24, color: AppColors.border, margin: const EdgeInsets.symmetric(horizontal: 12)),
Expanded( Expanded(
child: TextField( child: TextField(
controller: _phoneCtrl, controller: _phoneCtrl,
keyboardType: TextInputType.phone, keyboardType: TextInputType.phone,
maxLength: 11, maxLength: 11,
style: TextStyle(fontSize: 19, color: theme.colorScheme.foreground), style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration( decoration: const InputDecoration(
hintText: '请输入手机号', hintText: '请输入手机号',
hintStyle: TextStyle(color: theme.colorScheme.mutedForeground), hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
border: InputBorder.none, border: InputBorder.none,
counterText: '', counterText: '',
contentPadding: const EdgeInsets.symmetric(vertical: AppTheme.sLg), contentPadding: EdgeInsets.symmetric(vertical: 14),
), ),
), ),
), ),
]), ]),
); ),
} const SizedBox(height: 16),
Widget _buildCodeRow(ShadThemeData theme) { // 验证码
final codeDisabled = _countdown > 0 || _sending; Row(children: [
return Row(children: [
Expanded( Expanded(
child: Container( child: Container(
height: 52,
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.card, color: AppColors.backgroundSoft,
borderRadius: BorderRadius.circular(AppTheme.rSm), borderRadius: BorderRadius.circular(10),
boxShadow: [AppTheme.shadowLight],
), ),
child: TextField( child: TextField(
controller: _codeCtrl, controller: _codeCtrl,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
maxLength: 6, maxLength: 6,
style: TextStyle(fontSize: 19, color: theme.colorScheme.foreground), style: const TextStyle(fontSize: 16, color: AppColors.textPrimary),
decoration: InputDecoration( decoration: const InputDecoration(
hintText: '验证码', hintText: '验证码',
hintStyle: TextStyle(color: theme.colorScheme.mutedForeground), hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
border: InputBorder.none, border: InputBorder.none,
counterText: '', counterText: '',
contentPadding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sLg), contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 14),
), ),
), ),
), ),
), ),
const SizedBox(width: AppTheme.sMd), const SizedBox(width: 12),
GestureDetector( GestureDetector(
onTap: codeDisabled ? null : _sendSms, onTap: (_countdown > 0 || _sending) ? null : _sendSms,
child: AnimatedContainer( child: Container(
duration: const Duration(milliseconds: 200), width: 120, height: 52,
width: 110, height: 52,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
color: codeDisabled ? theme.colorScheme.muted : AppTheme.primary, color: (_countdown > 0 || _sending) ? AppColors.backgroundSoft : AppColors.primary,
borderRadius: BorderRadius.circular(AppTheme.rSm), borderRadius: BorderRadius.circular(10),
), ),
child: Text( child: Text(
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', _sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle( style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500,
fontSize: 17, color: (_countdown > 0 || _sending) ? AppColors.textHint : Colors.white),
color: codeDisabled ? theme.colorScheme.mutedForeground : Colors.white,
fontWeight: FontWeight.w500,
), ),
), ),
), ),
), ]),
]); const SizedBox(height: 12),
}
Widget _buildAgreement(ShadThemeData theme) { // 协议
return Align( GestureDetector(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: () => setState(() => _agreed = !_agreed), onTap: () => setState(() => _agreed = !_agreed),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Container( Container(
width: 18, height: 18, width: 18, height: 18, margin: const EdgeInsets.only(right: 6),
margin: const EdgeInsets.only(right: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _agreed ? AppTheme.primary : Colors.transparent, color: _agreed ? AppColors.primary : Colors.transparent,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
border: Border.all( border: Border.all(color: _agreed ? AppColors.primary : AppColors.border, width: 1.5),
color: _agreed ? AppTheme.primary : theme.colorScheme.border,
width: 1.5,
), ),
), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null,
child: _agreed ? Icon(LucideIcons.check, size: 15, color: theme.colorScheme.primaryForeground) : null,
), ),
RichText(text: TextSpan(children: [ RichText(text: TextSpan(children: [
TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), TextSpan(text: '《服务协议》', style: TextStyle(fontSize: 13, color: AppColors.primary)),
TextSpan(text: '', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), TextSpan(text: '', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), TextSpan(text: '《隐私政策》', style: TextStyle(fontSize: 13, color: AppColors.primary)),
])), ])),
]), ]),
), ),
);
}
Widget _buildLoginButton(ShadThemeData theme) { if (_error != null)
return GestureDetector( Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: AppColors.error, fontSize: 14))),
const SizedBox(height: 32),
// 登录按钮
GestureDetector(
onTap: _loading ? null : _login, onTap: _loading ? null : _login,
child: Container( child: Container(
width: double.infinity, width: double.infinity, height: 52, alignment: Alignment.center,
height: 52,
alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(colors: [Color(0xFF9B8FEF), AppTheme.primary]), gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppTheme.rPill), borderRadius: BorderRadius.circular(12),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))],
), ),
child: _loading child: _loading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white))
: const Text('登 录', style: TextStyle(fontSize: 20, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)), : const Text('登 录', style: TextStyle(fontSize: 18, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)),
),
),
const SizedBox(height: 40),
]),
))),
), ),
); );
} }

View File

@@ -70,7 +70,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
'isAbnormal': r['isAbnormal'] == true, 'isAbnormal': r['isAbnormal'] == true,
}); });
} }
} catch (_) {} } catch (e) { debugPrint('[Trend] 加载趋势失败: $e'); }
} }
all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime)); all.sort((a, b) => (b['date'] as DateTime).compareTo(a['date'] as DateTime));
if (mounted) { if (mounted) {

View File

@@ -40,9 +40,9 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
await [Permission.bluetoothScan, Permission.bluetoothConnect].request(); await [Permission.bluetoothScan, Permission.bluetoothConnect].request();
try { await FlutterBluePlus.turnOn(); } catch (_) {} try { await FlutterBluePlus.turnOn(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
try { await FlutterBluePlus.stopScan(); } catch (_) {} try { await FlutterBluePlus.stopScan(); } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
await FlutterBluePlus.startScan( await FlutterBluePlus.startScan(
timeout: const Duration(seconds: 30), timeout: const Duration(seconds: 30),
androidScanMode: AndroidScanMode.lowLatency, androidScanMode: AndroidScanMode.lowLatency,
@@ -72,7 +72,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
final api = ref.read(apiClientProvider); final api = ref.read(apiClientProvider);
await api.post('/api/health-records', data: reading.toHealthRecord()); await api.post('/api/health-records', data: reading.toHealthRecord());
await ref.read(omronDeviceProvider.notifier).onReading(reading); await ref.read(omronDeviceProvider.notifier).onReading(reading);
} catch (_) {} } catch (e) { debugPrint('[BLE Scan] 操作失败: $e'); }
}); });
if (mounted) { if (mounted) {
@@ -102,7 +102,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
body: Column(children: [ body: Column(children: [
Container( Container(
width: double.infinity, padding: const EdgeInsets.all(16), width: double.infinity, padding: const EdgeInsets.all(16),
color: AppColors.iconBackground, color: AppColors.iconBg,
child: Row(children: [ child: Row(children: [
if (_scanning) ...[ if (_scanning) ...[
const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))), const SizedBox(width: 16, height: 16, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF5B8DEF))),
@@ -128,7 +128,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
label: const Text('重新扫描'), label: const Text('重新扫描'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
foregroundColor: AppColors.iconColor, foregroundColor: AppColors.primary,
side: const BorderSide(color: Color(0xFF5B8DEF)), side: const BorderSide(color: Color(0xFF5B8DEF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
@@ -152,13 +152,13 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16), color: AppColors.cardBackground, borderRadius: BorderRadius.circular(16),
boxShadow: AppColors.cardShadow, boxShadow: AppColors.cardShadow,
border: isBP ? Border.all(color: AppColors.iconColor, width: 1.5) : null, border: isBP ? Border.all(color: AppColors.primary, width: 1.5) : null,
), ),
child: Row(children: [ child: Row(children: [
Container( Container(
width: 44, height: 44, width: 44, height: 44,
decoration: BoxDecoration(color: isBP ? AppColors.iconBackground : AppColors.backgroundSecondary, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: isBP ? AppColors.iconBg : AppColors.cardInner, borderRadius: BorderRadius.circular(12)),
child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.iconColor : AppColors.textHint), child: Icon(Icons.bluetooth, size: 24, color: isBP ? AppColors.primary : AppColors.textHint),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -167,7 +167,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), Flexible(child: Text(name, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))),
if (isBP) ...[ if (isBP) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))), Container(padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(4)), child: const Text('血压计', style: TextStyle(fontSize: 11, color: Colors.white))),
], ],
]), ]),
const SizedBox(height: 2), const SizedBox(height: 2),
@@ -179,7 +179,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
else else
GestureDetector( GestureDetector(
onTap: () => _connect(r), onTap: () => _connect(r),
child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.purpleBlueGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))), child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(12)), child: const Text('连接', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Colors.white))),
), ),
]), ]),
); );
@@ -202,7 +202,7 @@ class _DeviceScanPageState extends ConsumerState<DeviceScanPage> {
Widget _tip(String num, String text) => Padding( Widget _tip(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 8),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.iconColor, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))), Container(width: 20, height: 20, alignment: Alignment.center, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(10)), child: Text(num, style: const TextStyle(color: Colors.white, fontSize: 12, fontWeight: FontWeight.w600))),
const SizedBox(width: 10), const SizedBox(width: 10),
Expanded(child: Text(text, style: TextStyle(fontSize: 14, color: AppColors.textSecondary))), Expanded(child: Text(text, style: TextStyle(fontSize: 14, color: AppColors.textSecondary))),
]), ]),

View File

@@ -5,6 +5,7 @@ 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/navigation_provider.dart'; import '../../core/navigation_provider.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../utils/sse_handler.dart'; import '../../utils/sse_handler.dart';
@@ -123,7 +124,9 @@ class DietNotifier extends Notifier<DietState> {
if (text.isNotEmpty) state = state.copyWith(commentary: text.trim()); if (text.isNotEmpty) state = state.copyWith(commentary: text.trim());
} }
} }
} catch (_) {} } catch (e) {
debugPrint('[Diet] 获取饮食点评失败: $e');
}
} }
List<FoodItem> _parseFoodItems(String raw) { List<FoodItem> _parseFoodItems(String raw) {
@@ -243,7 +246,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
centerTitle: true, centerTitle: true,
), ),
body: state.imagePath == null body: state.imagePath == null
? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: Color(0xFF999999)))) ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary)))
: _buildResultView(context, ref), : _buildResultView(context, ref),
); );
} }
@@ -263,11 +266,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
return Container( return Container(
color: _kPageBg, color: AppColors.background,
child: SingleChildScrollView( child: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [ child: Column(children: [
_buildImagePreview(state.imagePath!), _buildImagePreview(state.imagePath!),
const SizedBox(height: 20), const SizedBox(height: 16),
_buildMealSelector(ref), _buildMealSelector(ref),
const SizedBox(height: 16), const SizedBox(height: 16),
if (state.isAnalyzing) if (state.isAnalyzing)
@@ -282,8 +286,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
_buildAiCommentary(state.commentary!), _buildAiCommentary(state.commentary!),
], ],
], ],
const SizedBox(height: 24), const SizedBox(height: 20),
_buildSubmitButton(context, ref), _buildSubmitButton(),
const SizedBox(height: 16), const SizedBox(height: 16),
], ],
]), ]),
@@ -293,29 +297,25 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
// ─────────── 图片预览 ─────────── // ─────────── 图片预览 ───────────
Widget _buildImagePreview(String path) { Widget _buildImagePreview(String path) {
return Stack( return Center(
children: [ child: ClipRRect(
Container( borderRadius: BorderRadius.circular(20),
height: 220,
decoration: BoxDecoration(
color: const Color(0xFFE0E0E0),
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
),
),
Positioned(
left: 0, right: 0, bottom: 0,
child: Container( child: Container(
height: 60, height: 200,
decoration: const BoxDecoration( width: MediaQuery.of(context).size.width * 0.75,
decoration: BoxDecoration(
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 14, offset: const Offset(0, 4))],
),
foregroundDecoration: const BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
colors: [Colors.transparent, Color(0x40000000)], colors: [Colors.transparent, Colors.black38],
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
), ),
), ),
), ),
), ),
],
); );
} }
@@ -351,9 +351,9 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
: null, : null,
), ),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(m.icon, size: 23, color: isSelected ? Colors.white : _kSubText), Icon(m.icon, size: 23, color: isSelected ? AppColors.textOnGradient : _kSubText),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? Colors.white : _kSubText)), Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? AppColors.textOnGradient : _kSubText)),
]), ]),
), ),
), ),
@@ -416,7 +416,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const Spacer(), const Spacer(),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(color: const Color(0xFFFFF8EE), borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)),
child: Text('$totalCal kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kWarning)), child: Text('$totalCal kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kWarning)),
), ),
IconButton( IconButton(
@@ -441,9 +441,9 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 200), duration: const Duration(milliseconds: 200),
decoration: BoxDecoration( decoration: BoxDecoration(
color: food.selected ? const Color(0xFFF6F4FF) : const Color(0xFFFAFAFA), color: food.selected ? AppColors.iconBg : AppColors.background,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : const Color(0xFFEEEEEE)), border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : AppColors.border),
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), padding: const EdgeInsets.fromLTRB(12, 10, 6, 10),
@@ -454,10 +454,10 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
width: 22, height: 22, width: 22, height: 22,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: food.selected ? _kPrimary : Colors.white, color: food.selected ? _kPrimary : AppTheme.surface,
border: Border.all(color: food.selected ? _kPrimary : const Color(0xFFCCCCCC), width: 2), border: Border.all(color: food.selected ? _kPrimary : AppColors.border, width: 2),
), ),
child: food.selected ? const Icon(Icons.check, size: 17, color: Colors.white) : null, child: food.selected ? const Icon(Icons.check, size: 17, color: AppColors.textOnGradient) : null,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@@ -514,7 +514,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
]), ]),
), ),
IconButton( IconButton(
icon: const Icon(Icons.close, size: 19, color: Color(0xFFBBBBBB)), icon: const Icon(Icons.close, size: 19, color: AppColors.textHint),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
constraints: const BoxConstraints(), constraints: const BoxConstraints(),
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
@@ -532,7 +532,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
child: Container( child: Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient(colors: [_kPrimary, Color(0xFF8B7CF0)], begin: Alignment.topLeft, end: Alignment.bottomRight), gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))], boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))],
), ),
@@ -548,12 +548,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
value: (totalCalories / 700).clamp(0.0, 1.0), value: (totalCalories / 700).clamp(0.0, 1.0),
strokeWidth: 4, strokeWidth: 4,
backgroundColor: Colors.white24, backgroundColor: Colors.white24,
color: Colors.white, color: AppColors.textOnGradient,
), ),
), ),
Column(mainAxisSize: MainAxisSize.min, children: [ Column(mainAxisSize: MainAxisSize.min, children: [
Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: Colors.white)), Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: AppColors.textOnGradient)),
Text('kcal', style: const TextStyle(fontSize: 13, color: Colors.white70)), Text('kcal', style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)),
]), ]),
], ],
), ),
@@ -561,14 +561,14 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const SizedBox(width: 16), const SizedBox(width: 16),
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('本餐热量', style: TextStyle(fontSize: 17, color: Colors.white70)), const Text('本餐热量', style: TextStyle(fontSize: 17, color: AppColors.textOnGradient)),
const SizedBox(height: 4), const SizedBox(height: 4),
Row(children: [ Row(children: [
Expanded(child: _macroBar('碳水', 0.55, const Color(0xFFFFF9C4))), Expanded(child: _macroBar('碳水', 0.55, AppColors.warningLight)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: _macroBar('蛋白', 0.25, const Color(0xFFD4C8FC))), Expanded(child: _macroBar('蛋白', 0.25, AppColors.iconBg)),
const SizedBox(width: 8), const SizedBox(width: 8),
Expanded(child: _macroBar('脂肪', 0.20, const Color(0xFFFFCDD2))), Expanded(child: _macroBar('脂肪', 0.20, AppColors.errorLight)),
]), ]),
]), ]),
), ),
@@ -582,7 +582,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
Row(children: [ Row(children: [
Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 13, color: Colors.white70)), Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)),
]), ]),
const SizedBox(height: 3), const SizedBox(height: 3),
ClipRRect( ClipRRect(
@@ -597,19 +597,22 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Container( child: Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _kSurface, gradient: AppColors.gLightPurple,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(16),
border: Border.all(color: _kBorder), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))],
), ),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Container( Container(
width: 32, height: 32, width: 36, height: 36,
decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(
child: const Icon(Icons.auto_awesome, size: 19, color: _kPrimary), gradient: AppColors.primaryGradient,
borderRadius: const BorderRadius.all(Radius.circular(10)),
), ),
const SizedBox(width: 10), child: const Icon(Icons.auto_awesome, size: 20, color: AppColors.textOnGradient),
),
const SizedBox(width: 12),
Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))), Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))),
]), ]),
), ),
@@ -617,49 +620,36 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
} }
// ─────────── 保存按钮 ─────────── // ─────────── 保存按钮 ───────────
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) { Widget _buildSubmitButton() {
return Padding( return SizedBox(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SizedBox(
width: double.infinity, width: double.infinity,
height: 50, height: 52,
child: ElevatedButton( child: Container(
onPressed: () async { decoration: BoxDecoration(
gradient: AppColors.gPurplePink,
borderRadius: BorderRadius.circular(14),
boxShadow: [BoxShadow(color: AppColors.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))],
),
padding: const EdgeInsets.all(1.5),
child: Material(
color: AppTheme.surface,
borderRadius: BorderRadius.circular(12.5),
child: InkWell(
onTap: () async {
try { try {
await ref.read(dietProvider.notifier).saveRecord(); await ref.read(dietProvider.notifier).saveRecord();
if (context.mounted) { if (mounted) popRoute(ref);
ScaffoldMessenger.of(context).showSnackBar(SnackBar( } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); }
content: const Text('饮食记录已保存'),
backgroundColor: _kPrimary,
behavior: SnackBarBehavior.floating,
duration: const Duration(seconds: 2),
));
popRoute(ref);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('保存失败: $e'),
backgroundColor: AppTheme.error,
behavior: SnackBarBehavior.floating,
));
}
}
}, },
style: ElevatedButton.styleFrom( borderRadius: BorderRadius.circular(12),
backgroundColor: _kPrimary,
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
textStyle: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600),
),
child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(Icons.check_circle_outline, size: 23), Icon(Icons.check_circle_outline, size: 22, color: AppColors.primary),
SizedBox(width: 8), SizedBox(width: 8),
Text('保存记录'), Text('保存记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)),
]), ]),
), ),
), ),
),
); );
} }
} }

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.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';
@@ -24,6 +25,7 @@ class _HomePageState extends ConsumerState<HomePage> {
String? _pickedImagePath; String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {}; final Set<ActiveAgent> _welcomedAgents = {};
final _focusNode = FocusNode(); final _focusNode = FocusNode();
int _lastMsgCount = 0;
@override void initState() { super.initState(); } @override void initState() { super.initState(); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); } @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); _focusNode.dispose(); super.dispose(); }
@@ -33,7 +35,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(); // 收起键盘 _focusNode.unfocus();
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);
@@ -46,8 +48,6 @@ class _HomePageState extends ConsumerState<HomePage> {
final chatState = ref.watch(chatProvider); final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final user = auth.user; final user = auth.user;
final selectedAgent = ref.watch(selectedAgentProvider);
final theme = ShadTheme.of(context);
ref.listen(cameraActionProvider, (prev, next) { ref.listen(cameraActionProvider, (prev, next) {
if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); } if (next == 'camera') { _pickImage(ImageSource.camera); ref.read(cameraActionProvider.notifier).clear(); }
@@ -58,49 +58,52 @@ class _HomePageState extends ConsumerState<HomePage> {
else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); } else if (next == 'pickFoodGallery') { _pickFoodImage(ImageSource.gallery); ref.read(dietActionProvider.notifier).clear(); }
}); });
final currentCount = chatState.messages.length;
if (currentCount > _lastMsgCount) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) _scrollCtrl.jumpTo(0);
});
}
_lastMsgCount = currentCount;
return Scaffold( return Scaffold(
drawer: const HealthDrawer(), drawer: const HealthDrawer(),
backgroundColor: theme.colorScheme.background, body: Container(
body: SafeArea( decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Column(children: [ child: Column(children: [
_buildHeader(user, theme), _buildHeader(user),
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
_buildBottomBar(context, selectedAgent, theme), _buildBottomBar(context),
]), ]),
), ),
),
); );
} }
// ═══════ 顶部栏 ═══════ // ═══════ 顶部栏 — 透明背景,与页面渐变无缝衔接 ═══════
Widget _buildHeader(dynamic user, ShadThemeData theme) { Widget _buildHeader(dynamic user) {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(bottom: BorderSide(color: theme.colorScheme.border, width: 0.5)),
),
child: Row(children: [ child: Row(children: [
Builder(builder: (ctx) => GestureDetector( Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(), onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar( child: Container(
radius: 20, width: 38, height: 38,
backgroundColor: AppTheme.primaryLight, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)),
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: const Icon(LucideIcons.menu, size: 20, color: AppColors.primary),
child: user?.avatarUrl == null ? const Icon(Icons.person, size: 28, color: AppTheme.primary) : null,
), ),
)), )),
const SizedBox(width: 10), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [ const Text('AI 健康管家', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
Icon(LucideIcons.bot, size: 19, color: AppTheme.primary),
const SizedBox(width: 4),
Text('AI 健康管家', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)),
]),
const SizedBox(height: 2),
Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}', Text('${_getGreeting()}${(user?.name != null && user!.name!.isNotEmpty) ? user.name : '用户'}',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
])), ])),
Icon(LucideIcons.bell, size: 25, color: theme.colorScheme.mutedForeground), GestureDetector(
onTap: () => pushRoute(ref, 'notificationPrefs'),
child: const Icon(LucideIcons.bell, size: 22, color: AppColors.textSecondary),
),
]), ]),
); );
} }
@@ -113,7 +116,7 @@ class _HomePageState extends ConsumerState<HomePage> {
return '晚上好'; return '晚上好';
} }
// ═══════ 智能体选择条 ═══════ // ═══════ 智能体胶囊 ═══════
static final _agentDefs = [ static final _agentDefs = [
(ActiveAgent.consultation, '问诊', LucideIcons.messageCircle), (ActiveAgent.consultation, '问诊', LucideIcons.messageCircle),
(ActiveAgent.health, '记数据', LucideIcons.heart), (ActiveAgent.health, '记数据', LucideIcons.heart),
@@ -123,32 +126,29 @@ class _HomePageState extends ConsumerState<HomePage> {
(ActiveAgent.exercise, '运动', LucideIcons.trendingUp), (ActiveAgent.exercise, '运动', LucideIcons.trendingUp),
]; ];
Widget _buildAgentBar(ActiveAgent? selected, ShadThemeData theme) { Widget _buildAgentBar() {
return Container( return SizedBox(
height: 40, height: 32,
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sMd),
child: ListView.separated( child: ListView.separated(
scrollDirection: Axis.horizontal, scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
itemCount: _agentDefs.length, itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6), separatorBuilder: (_, i) => const SizedBox(width: 8),
itemBuilder: (_, i) { itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i]; final (agent, label, icon) = _agentDefs[i];
return GestureDetector( return GestureDetector(
onTap: () { onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label),
// 用户标签 → 欢迎卡片,不发 AI 后端
ref.read(chatProvider.notifier).triggerAgent(agent, label);
},
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.card, color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rPill), borderRadius: BorderRadius.circular(16),
border: Border.all(color: theme.colorScheme.border), boxShadow: AppColors.cardShadowLight,
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: theme.colorScheme.mutedForeground), Icon(icon, size: 15, color: AppColors.primary),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: theme.colorScheme.mutedForeground)), Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primary)),
]), ]),
), ),
); );
@@ -157,122 +157,114 @@ class _HomePageState extends ConsumerState<HomePage> {
); );
} }
// ═══════ 底部区 ═══════ // ═══════ 底部输入区 ═══════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent, ShadThemeData theme) { Widget _buildBottomBar(BuildContext context) {
return Column(mainAxisSize: MainAxisSize.min, children: [ return Column(mainAxisSize: MainAxisSize.min, children: [
Container( Container(padding: const EdgeInsets.only(top: 4, bottom: 4), child: _buildAgentBar()),
padding: const EdgeInsets.only(top: AppTheme.sSm, bottom: 6), if (_pickedImagePath != null) _buildImagePreview(),
child: _buildAgentBar(selectedAgent, theme), _buildInputBar(),
),
if (_pickedImagePath != null) _buildImagePreview(theme),
_buildCompactInputBar(theme),
]); ]);
} }
Widget _buildImagePreview(ShadThemeData theme) { Widget _buildImagePreview() {
return Container( return Container(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4), padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: BoxDecoration( color: AppColors.cardInner,
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(children: [ child: Row(children: [
Stack(children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(AppTheme.rXs), borderRadius: BorderRadius.circular(8),
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover), child: Image.file(File(_pickedImagePath!), width: 48, height: 48, fit: BoxFit.cover),
), ),
Positioned(top: -4, right: -4, child: GestureDetector( const SizedBox(width: 10),
onTap: () => setState(() => _pickedImagePath = null), const Text('点击发送图片', style: TextStyle(fontSize: 14, color: AppColors.textSecondary)),
child: Container(
width: 20, height: 20,
decoration: const BoxDecoration(color: AppTheme.text, shape: BoxShape.circle),
child: const Icon(Icons.close, size: 17, color: Colors.white),
),
)),
]),
const Spacer(), const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 15, color: theme.colorScheme.mutedForeground)), GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: const Icon(Icons.close, size: 20, color: AppColors.textHint),
),
]), ]),
); );
} }
Widget _buildCompactInputBar(ShadThemeData theme) { Widget _buildInputBar() {
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: AppTheme.sSm, vertical: AppTheme.sSm), padding: const EdgeInsets.fromLTRB(12, 8, 12, 10),
decoration: BoxDecoration(
color: theme.colorScheme.card,
border: Border(top: BorderSide(color: theme.colorScheme.border)),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [
ShadButton.ghost( GestureDetector(
size: ShadButtonSize.sm, onTap: () => _showAttachmentPicker(context),
leading: Icon(LucideIcons.paperclip, size: 25, color: theme.colorScheme.mutedForeground), child: const Padding(
onPressed: () => _showAttachmentPicker(context, theme), padding: EdgeInsets.only(bottom: 10),
child: Icon(LucideIcons.paperclip, size: 24, color: AppColors.textSecondary),
), ),
),
const SizedBox(width: 8),
Expanded( Expanded(
child: Container(
constraints: const BoxConstraints(maxHeight: 120),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
),
child: TextField( child: TextField(
controller: _textCtrl, controller: _textCtrl,
focusNode: _focusNode, focusNode: _focusNode,
style: TextStyle(fontSize: 18, color: theme.colorScheme.foreground), style: const TextStyle(fontSize: 15, color: AppColors.textPrimary),
maxLines: null, maxLines: null,
textInputAction: TextInputAction.newline, textInputAction: TextInputAction.newline,
decoration: InputDecoration( decoration: const InputDecoration(
hintText: '输入你想说的...', hintText: '输入你想说的...',
hintStyle: TextStyle(fontSize: 18, color: theme.colorScheme.mutedForeground), hintStyle: TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true, filled: true, fillColor: Colors.white,
fillColor: theme.colorScheme.muted, contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 10),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
border: OutlineInputBorder( enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
borderRadius: BorderRadius.circular(AppTheme.rXl), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none),
borderSide: BorderSide.none,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rXl),
borderSide: BorderSide.none,
),
), ),
onSubmitted: (_) => _sendMessage(), onSubmitted: (_) => _sendMessage(),
), ),
), ),
const SizedBox(width: 4), ),
ShadButton( const SizedBox(width: 8),
size: ShadButtonSize.sm, GestureDetector(
leading: Icon(LucideIcons.send, size: 21, color: theme.colorScheme.primaryForeground), onTap: _sendMessage,
onPressed: _sendMessage, child: Container(
width: 40, height: 40,
margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4),
decoration: BoxDecoration(
color: AppColors.primary,
borderRadius: BorderRadius.circular(20),
),
child: const Icon(LucideIcons.send, size: 20, color: Colors.white),
),
), ),
]), ]),
); );
} }
void _showAttachmentPicker(BuildContext context, ShadThemeData theme) { void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet( showModalBottomSheet(
context: context, context: context,
backgroundColor: theme.colorScheme.card, backgroundColor: Colors.white,
shape: const RoundedRectangleBorder( shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(AppTheme.rXl)), borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
), ),
builder: (ctx) => SafeArea( builder: (ctx) => SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.sMd), padding: const EdgeInsets.symmetric(vertical: 8),
child: Wrap(children: [ child: Wrap(children: [
ListTile( ListTile(
leading: Icon(LucideIcons.camera, color: theme.colorScheme.foreground), leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary),
title: Text('拍照', style: TextStyle(color: theme.colorScheme.foreground)), title: const Text('拍照'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); },
), ),
ListTile( ListTile(
leading: Icon(LucideIcons.image, color: theme.colorScheme.foreground), leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary),
title: Text('从相册选', style: TextStyle(color: theme.colorScheme.foreground)), title: const Text('从相册选'),
onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); },
), ),
ListTile( ListTile(
leading: Icon(LucideIcons.file, color: theme.colorScheme.foreground), leading: const Icon(Icons.file_open_outlined, color: AppColors.primary),
title: Text('传文件', style: TextStyle(color: theme.colorScheme.foreground)), title: const Text('传文件'),
onTap: () async { onTap: () async {
Navigator.pop(ctx); Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(); final result = await FilePicker.platform.pickFiles();
@@ -289,8 +281,7 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
Future<void> _pickImage(ImageSource source) async { Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker(); final picked = await ImagePicker().pickImage(source: source, imageQuality: 85);
final picked = await picker.pickImage(source: source, imageQuality: 85);
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;
@@ -299,8 +290,7 @@ class _HomePageState extends ConsumerState<HomePage> {
} }
Future<void> _pickFoodImage(ImageSource source) async { Future<void> _pickFoodImage(ImageSource source) async {
final picker = ImagePicker(); final picked = await ImagePicker().pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
final picked = await picker.pickImage(source: source, imageQuality: 80, maxWidth: 1024, maxHeight: 1024);
if (picked != null && mounted) { if (picked != null && mounted) {
ref.read(dietProvider.notifier).reset(); ref.read(dietProvider.notifier).reset();
ref.read(dietProvider.notifier).setImage(picked.path); ref.read(dietProvider.notifier).setImage(picked.path);

View File

@@ -110,14 +110,15 @@ class ChatMessagesView extends ConsumerWidget {
constraints: BoxConstraints(maxWidth: screenWidth * 0.95), constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
boxShadow: AppColors.cardShadow, boxShadow: AppColors.cardShadow,
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ── 标题区域(清爽蓝紫渐变背景) ── // ── 标题区域 ──
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 22, 20, 20), padding: const EdgeInsets.fromLTRB(20, 22, 20, 20),
@@ -247,6 +248,15 @@ class ChatMessagesView extends ConsumerWidget {
), ),
], ],
// ── 医生选择区(问诊专用)──
if (agent == ActiveAgent.consultation) ...[
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: _buildDoctorCards(ref),
),
],
// ── 快捷操作按钮区 ── // ── 快捷操作按钮区 ──
if (actions.isNotEmpty) ...[ if (actions.isNotEmpty) ...[
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -550,14 +560,15 @@ class ChatMessagesView extends ConsumerWidget {
constraints: BoxConstraints(maxWidth: screenWidth * 0.95), constraints: BoxConstraints(maxWidth: screenWidth * 0.95),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(16),
border: Border.all(color: Color(0xFFE2E8F0), width: 2),
boxShadow: AppColors.cardShadow, boxShadow: AppColors.cardShadow,
), ),
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
// ── 标题区域(清爽蓝紫渐变背景) ── // ── 标题区域 ──
Container( Container(
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
@@ -758,10 +769,7 @@ class ChatMessagesView extends ConsumerWidget {
color: Colors.transparent, color: Colors.transparent,
child: InkWell( child: InkWell(
onTap: () { onTap: () {
msg.confirmed = true; ref.read(chatProvider.notifier).confirmMessage(msg.id);
if (isMedication) ref.invalidate(medicationListProvider);
if (isHealth) ref.invalidate(latestHealthProvider);
ref.read(chatProvider.notifier).markNeedsRebuild();
}, },
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
child: Row( child: Row(
@@ -816,8 +824,7 @@ class ChatMessagesView extends ConsumerWidget {
else else
InkWell( InkWell(
onTap: () { onTap: () {
msg.metadata?['_editingField'] = field.label; ref.read(chatProvider.notifier).startEditingField(msg.id, field.label);
ref.read(chatProvider.notifier).markNeedsRebuild();
}, },
child: Row( child: Row(
children: [ children: [
@@ -854,14 +861,10 @@ class ChatMessagesView extends ConsumerWidget {
border: InputBorder.none, border: InputBorder.none,
), ),
onSubmitted: (value) { onSubmitted: (value) {
msg.metadata?[field.label] = value; ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, value);
msg.metadata?['_editingField'] = null;
ref.read(chatProvider.notifier).markNeedsRebuild();
}, },
onTapOutside: (_) { onTapOutside: (_) {
msg.metadata?[field.label] = controller.text; ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, controller.text);
msg.metadata?['_editingField'] = null;
ref.read(chatProvider.notifier).markNeedsRebuild();
}, },
), ),
); );
@@ -1057,6 +1060,7 @@ class ChatMessagesView extends ConsumerWidget {
.replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '') .replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '')
.replaceAll(RegExp(r'`(.+?)`'), r'$1') .replaceAll(RegExp(r'`(.+?)`'), r'$1')
.replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') .replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1')
.replaceAll('\$1', '') // AI 偶尔输出 $1 占位符
.replaceAll(RegExp(r'\n{3,}'), '\n\n') .replaceAll(RegExp(r'\n{3,}'), '\n\n')
.trim(); .trim();
} }
@@ -1328,21 +1332,36 @@ class ChatMessagesView extends ConsumerWidget {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], border: Border.all(color: Color(0xFFE2E8F0), width: 2),
boxShadow: AppColors.cardShadow,
), ),
clipBehavior: Clip.antiAlias,
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Container(
Icon(Icons.today, size: 21, color: AppTheme.primary), width: double.infinity, padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
decoration: const BoxDecoration(gradient: LinearGradient(colors: [Color(0xFFF0ECFF), Color(0xFFEBF4FF)])),
child: Row(children: [
Container(width: 28, height: 28, decoration: BoxDecoration(color: Colors.white.withOpacity(0.6), borderRadius: BorderRadius.circular(8)),
child: const Icon(Icons.health_and_safety, size: 16, color: AppColors.primary)),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('今日任务', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const Text('今日健康', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
const Spacer(),
Text('${now.month}${now.day}', style: const TextStyle(fontSize: 13, color: AppColors.textSecondary)),
]), ]),
const SizedBox(height: 10), ),
Padding(
padding: const EdgeInsets.all(14),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
if (tasks.isEmpty)
_taskRow(context, Icons.check_circle, '今日暂无待办', status: 'done')
else
...tasks, ...tasks,
]), ]),
),
]),
); );
} }

View File

@@ -1,5 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
@@ -29,13 +31,13 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
ref.invalidate(medicationReminderProvider); ref.invalidate(medicationReminderProvider);
ref.invalidate(medicationListProvider); ref.invalidate(medicationListProvider);
_load(); _load();
} catch (_) {} } catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); }
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppTheme.bg,
appBar: AppBar(title: const Text('服药打卡'), centerTitle: true), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
@@ -46,9 +48,9 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
if (reminders.isEmpty) { if (reminders.isEmpty) {
return Center( return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.check_circle_outline, size: 64, color: Colors.grey[300]), Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint),
const SizedBox(height: 12), const SizedBox(height: 12),
const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: Color(0xFF999999))), const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)),
]), ]),
); );
} }
@@ -78,21 +80,21 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], boxShadow: [AppTheme.shadowCard],
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Container( Container(
width: 40, height: 40, width: 40, height: 40,
decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)),
child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))), child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)), Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
]), ]),
]), ]),
const SizedBox(height: 14), const SizedBox(height: 14),
@@ -107,12 +109,12 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
Icon( Icon(
isTaken ? Icons.check_circle : Icons.radio_button_unchecked, isTaken ? Icons.check_circle : Icons.radio_button_unchecked,
size: 23, size: 23,
color: isTaken ? AppTheme.success : const Color(0xFFCCCCCC), color: isTaken ? AppTheme.success : AppTheme.border,
), ),
const SizedBox(width: 10), const SizedBox(width: 10),
Text(time, style: TextStyle( Text(time, style: TextStyle(
fontSize: 18, fontWeight: FontWeight.w500, fontSize: 18, fontWeight: FontWeight.w500,
color: isTaken ? const Color(0xFF999999) : const Color(0xFF333333), color: isTaken ? AppTheme.textSub : AppTheme.text,
)), )),
const Spacer(), const Spacer(),
GestureDetector( GestureDetector(
@@ -120,12 +122,12 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isTaken ? const Color(0xFFDCFCE7) : AppTheme.primary, color: isTaken ? AppColors.successLight : AppTheme.primary,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
), ),
child: Text(isTaken ? '已打卡' : '打卡', child: Text(isTaken ? '已打卡' : '打卡',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600,
color: isTaken ? AppTheme.success : Colors.white)), color: isTaken ? AppTheme.success : AppColors.textOnGradient)),
), ),
), ),
]), ]),

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
@@ -57,7 +58,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
try { try {
if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); } if (widget.id != null) { await srv.update(widget.id!, data); } else { await srv.create(data); }
ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider); ref.invalidate(medicationListProvider); ref.invalidate(medicationReminderProvider);
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: AppTheme.success)); popRoute(ref); } if (mounted) { popRoute(ref); }
} catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.error)); } } catch (_) { if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: AppTheme.error)); }
if (mounted) setState(() => _loading = false); if (mounted) setState(() => _loading = false);
} }
@@ -113,24 +114,24 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
); );
} }
Widget _label(String text) => Text(text, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))); Widget _label(String text) => Text(text, style: TextStyle(fontSize: 17, color: AppColors.textSecondary));
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Widget _field(String label, TextEditingController ctrl, {String? hint}) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6), _label(label), const SizedBox(height: 6),
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)), TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppTheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)),
]); ]);
Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? AppTheme.primary : AppTheme.primaryLight, borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? Colors.white : AppTheme.primary, fontWeight: FontWeight.w500)))); Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? AppTheme.primary : AppTheme.primaryLight, borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? Colors.white : AppTheme.primary, fontWeight: FontWeight.w500))));
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Widget _dateField(String label, DateTime val, ValueChanged<DateTime> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6), _label(label), const SizedBox(height: 6),
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))), child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Text('${val.month}/${val.day}', style: const TextStyle(fontSize: 18)))),
]); ]);
Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Widget _dateFieldOpt(String label, DateTime? val, ValueChanged<DateTime?> cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_label(label), const SizedBox(height: 6), _label(label), const SizedBox(height: 6),
GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val ?? DateTime.now(), firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); },
child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), child: Row(children: [ child: Container(width: double.infinity, padding: const EdgeInsets.all(12), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : Colors.grey[400])), Text(val != null ? '${val.month}/${val.day}' : '不设置', style: TextStyle(fontSize: 18, color: val != null ? null : AppTheme.textHint)),
if (val != null) const Spacer(), if (val != null) const Spacer(),
if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: Color(0xFFBBBBBB))), if (val != null) GestureDetector(onTap: () => cb(null), child: const Icon(Icons.close, size: 19, color: AppColors.textHint)),
]))), ]))),
]); ]);
} }

View File

@@ -6,6 +6,7 @@ import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart'; import '../../providers/data_providers.dart';
import '../../widgets/app_empty_state.dart'; import '../../widgets/app_empty_state.dart';
import '../../widgets/app_tab_chip.dart'; import '../../widgets/app_tab_chip.dart';
import '../../widgets/common_widgets.dart';
class MedicationListPage extends ConsumerStatefulWidget { class MedicationListPage extends ConsumerStatefulWidget {
const MedicationListPage({super.key}); const MedicationListPage({super.key});
@@ -21,16 +22,17 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
Future<void> _delete(String id) async { Future<void> _delete(String id) async {
await ref.read(medicationServiceProvider).deleteMedication(id); await ref.read(medicationServiceProvider).deleteMedication(id);
ref.invalidate(medicationListProvider);
_load(); _load();
} }
static const _tabs = [('全部', ''), ('服用中', 'active'), ('已停药', 'inactive')]; static const _tabs = [('全部', ''), ('服用中', 'active'), ('已停药', 'inactive')];
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
backgroundColor: theme.colorScheme.background, backgroundColor: AppTheme.bg,
appBar: AppBar( appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
title: const Text('用药管理'), title: const Text('用药管理'),
actions: [ actions: [
ShadButton.ghost( ShadButton.ghost(
@@ -69,30 +71,15 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? ''; final times = (m['timeOfDay'] as List?)?.map((t) => t.toString().substring(0, 5)).join(' ') ?? '';
final isActive = m['isActive'] == true; final isActive = m['isActive'] == true;
final id = m['id']?.toString() ?? ''; final id = m['id']?.toString() ?? '';
return Dismissible( return SwipeDeleteTile(
key: Key(id), key: Key(id),
direction: DismissDirection.endToStart, onDelete: () => _delete(id),
confirmDismiss: (_) async {
await _delete(id);
return false;
},
background: Container(
margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4),
decoration: BoxDecoration(
color: theme.colorScheme.destructive,
borderRadius: BorderRadius.circular(AppTheme.rMd),
),
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
child: Icon(LucideIcons.trash, color: theme.colorScheme.destructiveForeground, size: 25),
),
child: GestureDetector(
onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4), margin: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: 4),
child: Container(
padding: const EdgeInsets.all(AppTheme.sLg), padding: const EdgeInsets.all(AppTheme.sLg),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.card, color: AppTheme.surface,
borderRadius: BorderRadius.circular(AppTheme.rMd), borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowLight], boxShadow: [AppTheme.shadowLight],
), ),
@@ -100,34 +87,33 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
Container( Container(
width: 44, height: 44, width: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
color: isActive ? AppTheme.primaryLight : theme.colorScheme.muted, color: isActive ? AppTheme.primaryLight : AppTheme.bgSoft,
borderRadius: BorderRadius.circular(AppTheme.rSm), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: Icon(LucideIcons.pill, size: 25, color: isActive ? AppTheme.primary : theme.colorScheme.mutedForeground), child: Icon(LucideIcons.pill, size: 25, color: isActive ? AppTheme.primary : AppTheme.textSub),
), ),
const SizedBox(width: AppTheme.sMd), const SizedBox(width: AppTheme.sMd),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? theme.colorScheme.foreground : theme.colorScheme.mutedForeground)), Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)),
if (!isActive) ...[ if (!isActive) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.muted, color: AppTheme.bgSoft,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: Text('已停', style: TextStyle(fontSize: 13, color: theme.colorScheme.mutedForeground)), child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)),
), ),
], ],
]), ]),
const SizedBox(height: 2), const SizedBox(height: 2),
Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: theme.colorScheme.mutedForeground)), Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)),
])), ])),
Icon(LucideIcons.chevronRight, size: 21, color: theme.colorScheme.border), Icon(LucideIcons.chevronRight, size: 21, color: AppTheme.border),
]), ]),
), ),
),
); );
}, },
); );

View File

@@ -1,10 +1,9 @@
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 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.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_menu_item.dart';
class ProfilePage extends ConsumerWidget { class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key}); const ProfilePage({super.key});
@@ -12,100 +11,178 @@ class ProfilePage extends ConsumerWidget {
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final user = auth.user; final user = auth.user;
final theme = ShadTheme.of(context); final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称';
final phone = user?.phone ?? '';
return Scaffold( return Scaffold(
backgroundColor: theme.colorScheme.background, backgroundColor: AppColors.background,
body: SafeArea(child: SingleChildScrollView( appBar: AppBar(
padding: const EdgeInsets.only(bottom: AppTheme.sXl), backgroundColor: AppColors.cardBackground,
title: const Text('个人信息'),
actions: [
TextButton(
onPressed: () => pushRoute(ref, 'healthArchive'),
child: const Text('编辑档案', style: TextStyle(fontSize: 15, color: AppColors.primary)),
),
],
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(children: [ child: Column(children: [
// 用户头部卡片 const SizedBox(height: 20),
// 头像区
Center(
child: Stack(children: [
Container( Container(
width: double.infinity, padding: const EdgeInsets.all(3),
padding: const EdgeInsets.all(AppTheme.rXl),
decoration: BoxDecoration( decoration: BoxDecoration(
color: theme.colorScheme.card, gradient: AppColors.gPurplePink,
borderRadius: const BorderRadius.only( shape: BoxShape.circle,
bottomLeft: Radius.circular(AppTheme.rXl),
bottomRight: Radius.circular(AppTheme.rXl),
), ),
boxShadow: [AppTheme.shadowLight], child: CircleAvatar(
), radius: 50,
child: InkWell( backgroundColor: AppColors.cardBackground,
onTap: () => pushRoute(ref, 'healthArchive'),
borderRadius: BorderRadius.circular(AppTheme.rSm),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: AppTheme.sSm),
child: Row(children: [
Stack(children: [
CircleAvatar(
radius: 32,
backgroundColor: AppTheme.primaryLight,
backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null,
child: user?.avatarUrl == null ? const Icon(Icons.person, size: 36, color: AppTheme.primary) : null, child: user?.avatarUrl == null
? const Icon(Icons.person, size: 48, color: AppColors.primary)
: null,
),
), ),
Positioned( Positioned(
right: 0, bottom: 0, right: 0, bottom: 0,
child: Container( child: Container(
width: 20, height: 20, width: 34, height: 34,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primary, gradient: AppColors.gPurplePink,
borderRadius: BorderRadius.circular(10), shape: BoxShape.circle,
border: Border.all(color: theme.colorScheme.card, width: 2), border: Border.all(color: AppColors.cardBackground, width: 3),
), ),
child: const Icon(Icons.edit, size: 13, color: Colors.white), child: const Icon(Icons.camera_alt, size: 17, color: AppColors.textOnGradient),
), ),
), ),
]), ]),
const SizedBox(width: AppTheme.sLg),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(user?.name ?? '未设置昵称',
style: TextStyle(fontSize: 23, fontWeight: FontWeight.bold, color: theme.colorScheme.foreground)),
const SizedBox(height: 4),
Text(user?.phone ?? '', style: TextStyle(fontSize: 17, color: theme.colorScheme.mutedForeground)),
])),
Icon(LucideIcons.chevronRight, size: 25, color: theme.colorScheme.mutedForeground),
]),
), ),
), const SizedBox(height: 20),
), Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
const SizedBox(height: AppTheme.sMd), const SizedBox(height: 6),
AppMenuItem(icon: LucideIcons.folderArchive, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), Container(
AppMenuItem(icon: LucideIcons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6),
const SizedBox(height: 40),
// 退出登录
GestureDetector(
onTap: () => _logout(context, ref),
child: Container(
margin: const EdgeInsets.symmetric(horizontal: AppTheme.rXl),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.destructive.withAlpha(80)), color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(AppTheme.rPill), borderRadius: BorderRadius.circular(AppTheme.rPill),
), ),
child: Text('退出登录', style: TextStyle(fontSize: 19, color: theme.colorScheme.destructive, fontWeight: FontWeight.w500)), child: Row(mainAxisSize: MainAxisSize.min, children: [
), const Icon(Icons.phone_android, size: 16, color: AppColors.primary),
), const SizedBox(width: 6),
Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.primary)),
]), ]),
)), ),
const SizedBox(height: 32),
// 基础信息卡片
Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [AppTheme.shadowCard],
),
child: Column(children: [
_infoRow(Icons.person_outline, '姓名', name),
const Divider(height: 24, color: AppColors.borderLight),
_infoRow(Icons.phone_android, '手机号', phone.isNotEmpty ? phone : '未绑定'),
const Divider(height: 24, color: AppColors.borderLight),
_infoRow(Icons.health_and_safety, '健康档案', '查看详情', onTap: () => pushRoute(ref, 'healthArchive')),
]),
),
const SizedBox(height: 24),
// 快捷入口
Row(children: [
Expanded(child: _actionCard(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))),
const SizedBox(width: 12),
Expanded(child: _actionCard(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))),
]),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _actionCard(Icons.settings_outlined, '设置', () => pushRoute(ref, 'settings'))),
const SizedBox(width: 12),
Expanded(child: _actionCard(Icons.logout, '退出登录', () => _logout(context, ref), isDestructive: true)),
]),
const SizedBox(height: 40),
]),
),
),
);
}
Widget _infoRow(IconData icon, String label, String value, {VoidCallback? onTap}) {
return GestureDetector(
onTap: onTap,
child: Row(children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 22, color: AppColors.primary),
),
const SizedBox(width: 14),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textHint)),
const SizedBox(height: 3),
Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
]),
),
if (onTap != null) const Icon(Icons.chevron_right, size: 20, color: AppColors.textHint),
]),
);
}
Widget _actionCard(IconData icon, String label, VoidCallback onTap, {bool isDestructive = false}) {
final c = isDestructive ? AppColors.error : AppColors.primary;
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowLight],
),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Icon(icon, size: 20, color: c),
const SizedBox(width: 8),
Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isDestructive ? c : AppColors.textPrimary)),
]),
),
); );
} }
Future<void> _logout(BuildContext context, WidgetRef ref) async { Future<void> _logout(BuildContext context, WidgetRef ref) async {
final theme = ShadTheme.of(context);
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)),
title: Text('退出登录', style: TextStyle(color: theme.colorScheme.foreground)), title: const Text('退出登录'),
content: Text('确定退出?', style: TextStyle(color: theme.colorScheme.mutedForeground)), content: const Text('确定退出当前账号'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: theme.colorScheme.mutedForeground))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('确定', style: TextStyle(color: theme.colorScheme.destructive))), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))),
], ],
), ),
); );
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } if (ok == true) {
await ref.read(authProvider.notifier).logout();
goRoute(ref, 'login');
}
} }
} }

View File

@@ -7,6 +7,7 @@ import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/data_providers.dart'; import '../providers/data_providers.dart';
import '../providers/omron_device_provider.dart'; import '../providers/omron_device_provider.dart';
import '../widgets/common_widgets.dart';
/// 饮食记录列表(左滑删除) /// 饮食记录列表(左滑删除)
class DietRecordListPage extends ConsumerStatefulWidget { class DietRecordListPage extends ConsumerStatefulWidget {
@@ -22,7 +23,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppTheme.bg,
appBar: AppBar(title: const Text('饮食记录')), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
@@ -38,7 +39,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? ''; final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? '';
final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'}; final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'};
return _SwipeDeleteTile( return SwipeDeleteTile(
key: Key(d['id']?.toString() ?? '$i'), key: Key(d['id']?.toString() ?? '$i'),
onDelete: () async { onDelete: () async {
await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? ''); await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? '');
@@ -46,11 +47,10 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
}, },
onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}), onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}),
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [ child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))), Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Center(child: Text(mealIcons[d['mealType']?.toString()] ?? '🍽️', style: const TextStyle(fontSize: 23)))),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
@@ -59,9 +59,9 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)), Text('${d['totalCalories'] ?? 0}千卡', style: const TextStyle(fontSize: 16, color: AppTheme.primary)),
]), ]),
const SizedBox(height: 4), const SizedBox(height: 4),
Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: Color(0xFF888888)), maxLines: 1, overflow: TextOverflow.ellipsis), Text(items.map((f) => f['name']).join(' · '), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary), maxLines: 1, overflow: TextOverflow.ellipsis),
])), ])),
const Icon(Icons.chevron_right, size: 21, color: Color(0xFFCCCCCC)), Icon(Icons.chevron_right, size: 21, color: AppColors.textHint),
]), ]),
), ),
); );
@@ -81,7 +81,7 @@ class DietRecordDetailPage extends ConsumerWidget {
final service = ref.watch(dietServiceProvider); final service = ref.watch(dietServiceProvider);
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppTheme.bg,
appBar: AppBar(title: const Text('饮食详情')), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: service.getRecords(), future: service.getRecords(),
builder: (ctx, snap) { builder: (ctx, snap) {
@@ -92,23 +92,23 @@ class DietRecordDetailPage extends ConsumerWidget {
final totalCal = d['totalCalories'] ?? 0; final totalCal = d['totalCalories'] ?? 0;
final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'};
return ListView(padding: const EdgeInsets.all(16), children: [ return ListView(padding: const EdgeInsets.all(16), children: [
Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), child: Row(children: [ Container(padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(14)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)), Container(width: 48, height: 48, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.restaurant, size: 28, color: AppTheme.primary)),
const SizedBox(width: 14), const SizedBox(width: 14),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), Text(mealNames[d['mealType']?.toString()] ?? '', style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: Color(0xFF999999))), Text('$totalCal 千卡 · ${d['recordedAt'] ?? ''}', style: const TextStyle(fontSize: 16, color: AppColors.textHint)),
]), ]),
])), ])),
const SizedBox(height: 16), const SizedBox(height: 16),
...items.map((f) => Container( ...items.map((f) => Container(
margin: const EdgeInsets.only(bottom: 8), margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [ child: Row(children: [
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), Text(f['name']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), if ((f['portion']?.toString() ?? '').isNotEmpty) Text(f['portion']!.toString(), style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
])), ])),
Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)), Text('${f['calories'] ?? 0} kcal', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600)),
]), ]),
@@ -138,13 +138,14 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
Future<void> _deletePlan(String id) async { Future<void> _deletePlan(String id) async {
await ref.read(exerciseServiceProvider).deletePlan(id); await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider);
_load(); _load();
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppTheme.bg,
appBar: AppBar(title: const Text('运动计划')), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')),
floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)), floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future, future: _future,
@@ -157,51 +158,44 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
if (items.isEmpty) return const SizedBox.shrink(); if (items.isEmpty) return const SizedBox.shrink();
final total = items.length; final total = items.length;
final done = items.where((it) => it['isCompleted'] == true).length; final done = items.where((it) => it['isCompleted'] == true).length;
// 基于weekStartDate推算今天对应的条目索引
final weekStart = p['weekStartDate']?.toString() ?? ''; final weekStart = p['weekStartDate']?.toString() ?? '';
int? todayIdx; // 用 DayOfWeek 匹配今天C#: 0=Sun, 6=Sat; Dart weekday%7 对齐)
if (weekStart.isNotEmpty) { final todayCsDow = DateTime.now().weekday % 7;
final ws = DateTime.tryParse(weekStart); final todayItem = items.cast<Map<String, dynamic>>().firstWhere(
if (ws != null) { (it) => it['dayOfWeek'] == todayCsDow,
final diff = DateTime.now().difference(ws).inDays; orElse: () => <String, dynamic>{},
if (diff >= 0 && diff < items.length) todayIdx = diff; );
} final hasTodayItem = todayItem.isNotEmpty;
} final todayDone = todayItem['isCompleted'] == true;
final todayItem = todayIdx != null ? items[todayIdx] : null;
final todayDone = todayItem?['isCompleted'] == true;
final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; final exerciseName = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动';
final startDate = weekStart;
final endIdx = items.length - 1;
final endDay = items.isNotEmpty ? items.last['dayOfWeek'] as int? : 6;
return _SwipeDeleteTile( return SwipeDeleteTile(
key: Key(p['id']?.toString() ?? '$i'), key: Key(p['id']?.toString() ?? '$i'),
onDelete: () => _deletePlan(p['id']?.toString() ?? ''), onDelete: () => _deletePlan(p['id']?.toString() ?? ''),
onTap: () {}, onTap: () {},
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [ child: Row(children: [
Container(width: 44, height: 44, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)), Container(width: 44, height: 44, decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rMd)), child: const Icon(Icons.directions_run, size: 25, color: AppTheme.primary)),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), Text(exerciseName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
const SizedBox(height: 2), const SizedBox(height: 2),
Text('$startDate 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), Text('$weekStart 起 · ${items.first['durationMinutes'] ?? 0}分钟/天', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
const SizedBox(height: 6), const SizedBox(height: 6),
Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)), Text('$done/$total 天已完成', style: const TextStyle(fontSize: 15, color: AppTheme.primary)),
])), ])),
GestureDetector( GestureDetector(
onTap: todayItem != null ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null, onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
width: 40, height: 40, width: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: todayDone ? const Color(0xFFDCFCE7) : (todayItem != null ? AppTheme.primaryLight : const Color(0xFFF5F5F5)), color: todayDone ? AppColors.successLight : (hasTodayItem ? AppTheme.primaryLight : AppColors.cardInner),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : const Color(0xFFBBBBBB)), child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint),
), ),
), ),
]), ]),
@@ -237,13 +231,13 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}', 'endDate': '${_end.year}-${_end.month.toString().padLeft(2,'0')}-${_end.day.toString().padLeft(2,'0')}',
}); });
ref.invalidate(currentExercisePlanProvider); ref.invalidate(currentExercisePlanProvider);
if (mounted) { ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('计划已创建'), backgroundColor: Color(0xFF43A047))); popRoute(ref); } if (mounted) { popRoute(ref); }
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppTheme.bg,
appBar: AppBar(title: const Text('新建计划')), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')),
body: ListView(padding: const EdgeInsets.all(16), children: [ body: ListView(padding: const EdgeInsets.all(16), children: [
_field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'), _field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'),
const SizedBox(height: 16), const SizedBox(height: 16),
@@ -253,22 +247,22 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
const SizedBox(height: 16), const SizedBox(height: 16),
_dateField('结束日期', _end, (d) => setState(() => _end = d)), _dateField('结束日期', _end, (d) => setState(() => _end = d)),
const SizedBox(height: 32), const SizedBox(height: 32),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))), SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)), padding: const EdgeInsets.symmetric(vertical: 14)), child: const Text('保存计划', style: TextStyle(fontSize: 19)))),
]), ]),
); );
} }
Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) { Widget _field(String label, TextEditingController ctrl, {String? hint, bool number = false}) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))), Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6), const SizedBox(height: 6),
TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)), TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppColors.backgroundSoft, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)),
]); ]);
} }
Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) { Widget _dateField(String label, DateTime val, ValueChanged<DateTime> onChanged) {
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(label, style: const TextStyle(fontSize: 17, color: Color(0xFF666666))), Text(label, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)),
const SizedBox(height: 6), const SizedBox(height: 6),
GestureDetector( GestureDetector(
onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); }, onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) onChanged(d); },
@@ -294,7 +288,7 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('复查随访'), centerTitle: true), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('复查随访'), centerTitle: true),
body: FutureBuilder<List<Map<String, dynamic>>>( body: FutureBuilder<List<Map<String, dynamic>>>(
future: _future, future: _future,
builder: (ctx, snap) { builder: (ctx, snap) {
@@ -305,11 +299,11 @@ class _FollowUpListPageState extends ConsumerState<FollowUpListPage> {
if (list.isEmpty) { if (list.isEmpty) {
return Center( return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.event_note_outlined, size: 64, color: Colors.grey[300]), Icon(Icons.event_note_outlined, size: 64, color: AppColors.textHint),
const SizedBox(height: 12), const SizedBox(height: 12),
Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium), Text('暂无随访安排', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), const Text('医生创建随访后将显示在这里', style: TextStyle(fontSize: 16, color: AppColors.textHint)),
]), ]),
); );
} }
@@ -338,19 +332,19 @@ class _FollowUpItem extends StatelessWidget {
Color _statusColor(String? status) { Color _statusColor(String? status) {
switch (status) { switch (status) {
case 'Upcoming': return const Color(0xFF4F6EF7); case 'Upcoming': return AppColors.primary;
case 'Completed': return AppTheme.success; case 'Completed': return AppTheme.success;
case 'Cancelled': return AppTheme.error; case 'Cancelled': return AppTheme.error;
default: return const Color(0xFF999999); default: return AppColors.textHint;
} }
} }
Color _statusBg(String? status) { Color _statusBg(String? status) {
switch (status) { switch (status) {
case 'Upcoming': return const Color(0xFFEDF2FF); case 'Upcoming': return AppColors.iconBg;
case 'Completed': return const Color(0xFFDCFCE7); case 'Completed': return AppColors.successLight;
case 'Cancelled': return const Color(0xFFFFF5F5); case 'Cancelled': return AppColors.errorLight;
default: return const Color(0xFFF5F5F5); default: return AppColors.backgroundSoft;
} }
} }
@@ -366,28 +360,28 @@ class _FollowUpItem extends StatelessWidget {
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], boxShadow: [AppTheme.shadowCard],
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)), child: Text(label, style: TextStyle(fontSize: 15, color: color, fontWeight: FontWeight.w500)),
), ),
const Spacer(), const Spacer(),
if (item['doctorName'] != null) if (item['doctorName'] != null)
Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), Text('👨‍⚕️ ${item['doctorName']}', style: const TextStyle(fontSize: 15, color: AppColors.textHint)),
]), ]),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), Text(item['title']?.toString() ?? '', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
const SizedBox(height: 4), const SizedBox(height: 4),
if (item['scheduledAt'] != null) if (item['scheduledAt'] != null)
Text(_formatDateTime(item['scheduledAt']?.toString()), style: TextStyle(fontSize: 17, color: Colors.grey[500])), Text(_formatDateTime(item['scheduledAt']?.toString()), style: const TextStyle(fontSize: 17, color: AppColors.textHint)),
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[ if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 16, color: Colors.grey[600])), Text(item['notes']?.toString() ?? '', style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
], ],
]), ]),
); );
@@ -419,6 +413,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
final _dietCtrl = TextEditingController(); final _dietCtrl = TextEditingController();
final _familyCtrl = TextEditingController(); final _familyCtrl = TextEditingController();
bool _loading = true; bool _loading = true;
bool _saving = false;
@override void initState() { super.initState(); _load(); } @override void initState() { super.initState(); _load(); }
@override void dispose() { @override void dispose() {
@@ -452,8 +447,9 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
} }
Future<void> _save() async { Future<void> _save() async {
final srv = ref.read(userServiceProvider); setState(() => _saving = true);
try { try {
final srv = ref.read(userServiceProvider);
await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text); await srv.updateProfile(name: _nameCtrl.text, gender: _genderCtrl.text, birthDate: _birthCtrl.text);
await srv.updateHealthArchive({ await srv.updateHealthArchive({
'diagnosis': _diagnosisCtrl.text, 'diagnosis': _diagnosisCtrl.text,
@@ -464,63 +460,145 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
'dietRestrictions': _dietCtrl.text.split('').where((s) => s.isNotEmpty).toList(), 'dietRestrictions': _dietCtrl.text.split('').where((s) => s.isNotEmpty).toList(),
'familyHistory': _familyCtrl.text, 'familyHistory': _familyCtrl.text,
}); });
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已保存'), backgroundColor: Color(0xFF43A047))); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存成功'), backgroundColor: AppColors.success),
);
}
} catch (_) { } catch (_) {
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存失败'), backgroundColor: Color(0xFFE53935))); if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('保存失败,请重试'), backgroundColor: AppColors.error),
);
}
} finally {
if (mounted) setState(() => _saving = false);
} }
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
if (_loading) return Scaffold(appBar: AppBar(title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: AppTheme.primary))); if (_loading) {
return Scaffold( return Scaffold(
backgroundColor: AppTheme.bg, backgroundColor: AppColors.background,
appBar: AppBar(title: const Text('健康档案'), actions: [ appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')),
TextButton(onPressed: _save, child: const Text('保存', style: TextStyle(color: AppTheme.primary, fontWeight: FontWeight.w600))), body: const Center(child: CircularProgressIndicator(color: AppColors.primary)),
);
}
return Scaffold(
backgroundColor: AppColors.background,
appBar: AppBar(
backgroundColor: AppColors.cardBackground,
title: const Text('健康档案'),
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
_buildSection('个人资料', Icons.person_outline, [
_buildField('姓名', _nameCtrl, hint: '请输入姓名'),
const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('性别', _genderCtrl, hint: '男/女')),
const SizedBox(width: 12),
Expanded(child: _buildField('出生日期', _birthCtrl, hint: '如 1970-03-15')),
]), ]),
body: ListView(padding: const EdgeInsets.all(16), children: [
_sectionTitle('个人资料'),
_editableCard(children: [
_field('姓名', _nameCtrl),
Row(children: [Expanded(child: _field('性别', _genderCtrl)), const SizedBox(width: 12), Expanded(child: _field('出生日期', _birthCtrl, hint: 'YYYY-MM-DD'))]),
]), ]),
_sectionTitle('医疗信息'), const SizedBox(height: 16),
_editableCard(children: [ _buildSection('医疗信息', Icons.local_hospital_outlined, [
_field('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'), _buildField('主要诊断', _diagnosisCtrl, hint: '如: 冠心病'),
Row(children: [Expanded(child: _field('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')), const SizedBox(width: 12), Expanded(child: _field('手术日期', _surgeryDateCtrl, hint: 'YYYY-MM-DD'))]), const SizedBox(height: 12),
Row(children: [
Expanded(child: _buildField('手术类型', _surgeryCtrl, hint: '如: PCI支架植入术')),
const SizedBox(width: 12),
Expanded(child: _buildField('手术日期', _surgeryDateCtrl, hint: '如 2026-03-15')),
]), ]),
_sectionTitle('病史与限制'),
_editableCard(children: [
_field('过敏史', _allergiesCtrl, hint: '多个用、分隔 如: 青霉素、海鲜'),
_field('慢性病史', _chronicCtrl, hint: '多个用、分隔 如: 高血压、高血脂'),
_field('饮食限制', _dietCtrl, hint: '多个用、分隔 如: 低盐、低脂'),
_field('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]), ]),
const SizedBox(height: 20), const SizedBox(height: 16),
SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _save, child: const Text('保存档案')),), _buildSection('病史与限制', Icons.warning_amber_outlined, [
_buildField('过敏史', _allergiesCtrl, hint: '多个用、分隔,如: 青霉素、海鲜'),
const SizedBox(height: 12),
_buildField('慢性病史', _chronicCtrl, hint: '多个用、分隔,如: 高血压、高血脂'),
const SizedBox(height: 12),
_buildField('饮食限制', _dietCtrl, hint: '多个用、分隔,如: 低盐、低脂'),
const SizedBox(height: 12),
_buildField('家族病史', _familyCtrl, hint: '如: 父亲冠心病'),
]),
const SizedBox(height: 24),
SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton(
onPressed: _saving ? null : _save,
style: ElevatedButton.styleFrom(
backgroundColor: AppColors.primary,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
elevation: 0,
),
child: Text(_saving ? '保存中...' : '保存档案', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
),
),
const SizedBox(height: 40), const SizedBox(height: 40),
],
),
);
}
Widget _buildSection(String title, IconData icon, List<Widget> fields) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: [AppTheme.shadowCard],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: AppColors.iconBg,
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, size: 20, color: AppColors.primary),
),
const SizedBox(width: 10),
Text(title, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w700, color: AppColors.textPrimary)),
]),
const SizedBox(height: 16),
...fields,
]), ]),
); );
} }
Widget _sectionTitle(String title) => Padding( Widget _buildField(String label, TextEditingController ctrl, {String? hint}) {
padding: const EdgeInsets.only(left: 4, top: 16, bottom: 8), return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
child: Text(title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppTheme.primary)), Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textHint)),
); const SizedBox(height: 6),
TextField(
Widget _editableCard({required List<Widget> children}) => Container( controller: ctrl,
padding: const EdgeInsets.all(16), style: const TextStyle(fontSize: 17, color: AppColors.textPrimary),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(8), blurRadius: 8, offset: const Offset(0, 2))]), decoration: InputDecoration(
child: Column(children: children), hintText: hint,
); hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint),
filled: true,
Widget _field(String label, TextEditingController ctrl, {String? hint}) => Padding( fillColor: AppColors.backgroundSoft,
padding: const EdgeInsets.only(bottom: 12), contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ border: OutlineInputBorder(
Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF888888))), borderRadius: BorderRadius.circular(AppTheme.rSm),
const SizedBox(height: 4), borderSide: BorderSide.none,
TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: const Color(0xFFF4F5FA), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 18)), ),
]), enabledBorder: OutlineInputBorder(
); borderRadius: BorderRadius.circular(AppTheme.rSm),
borderSide: BorderSide.none,
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(AppTheme.rSm),
borderSide: const BorderSide(color: AppColors.primary, width: 1.5),
),
),
),
]);
}
} }
/// 健康日历 /// 健康日历
@@ -552,12 +630,12 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
events[map['date'] as String] = List<String>.from(map['events'] ?? []); events[map['date'] as String] = List<String>.from(map['events'] ?? []);
} }
if (mounted) setState(() => _events = events); if (mounted) setState(() => _events = events);
} catch (_) {} } catch (e) { debugPrint('[Calendar] 加载日历失败: $e'); }
} }
@override Widget build(BuildContext context) { @override Widget build(BuildContext context) {
return Scaffold( return Scaffold(
appBar: AppBar(title: const Text('健康日历'), centerTitle: true), appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true),
body: Column(children: [ body: Column(children: [
_buildMonthHeader(), _buildMonthHeader(),
_buildWeekdayHeader(), _buildWeekdayHeader(),
@@ -591,7 +669,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
Widget _buildWeekdayHeader() { Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', '']; const weekdays = ['', '', '', '', '', '', ''];
return Row(children: weekdays.map((day) => Expanded( return Row(children: weekdays.map((day) => Expanded(
child: Center(child: Text(day, style: TextStyle(fontSize: 17, color: Colors.grey[500]))), child: Center(child: Text(day, style: const TextStyle(fontSize: 17, color: AppColors.textHint))),
)).toList()); )).toList());
} }
@@ -638,7 +716,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
'$day', '$day',
style: TextStyle( style: TextStyle(
fontSize: 19, fontSize: 19,
color: isToday ? Colors.white : Colors.black, color: isToday ? Colors.white : AppColors.textPrimary,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal, fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
), ),
), ),
@@ -669,8 +747,8 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
switch (type) { switch (type) {
case 'medication': return AppTheme.primary; case 'medication': return AppTheme.primary;
case 'exercise': return AppTheme.success; case 'exercise': return AppTheme.success;
case 'followup': return const Color(0xFFF59E0B); case 'followup': return AppColors.warning;
default: return Colors.grey; default: return AppColors.textHint;
} }
} }
@@ -678,14 +756,14 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
final items = [ final items = [
{'color': AppTheme.primary, 'label': '用药提醒'}, {'color': AppTheme.primary, 'label': '用药提醒'},
{'color': AppTheme.success, 'label': '运动计划'}, {'color': AppTheme.success, 'label': '运动计划'},
{'color': const Color(0xFFF59E0B), 'label': '复查随访'}, {'color': AppColors.warning, 'label': '复查随访'},
]; ];
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [ child: Row(mainAxisAlignment: MainAxisAlignment.center, children: items.map((item) => Row(children: [
Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))), Container(width: 10, height: 10, decoration: BoxDecoration(color: item['color'] as Color, borderRadius: BorderRadius.circular(5))),
const SizedBox(width: 4), const SizedBox(width: 4),
Text(item['label'] as String, style: TextStyle(fontSize: 15, color: Colors.grey[600])), Text(item['label'] as String, style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)),
const SizedBox(width: 20), const SizedBox(width: 20),
])).toList()), ])).toList()),
); );
@@ -774,12 +852,12 @@ class StaticTextPage extends ConsumerWidget {
backgroundColor: Colors.white, backgroundColor: Colors.white,
elevation: 0, elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)), leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), title: Text(titles[type] ?? '', style: TextStyle(color: AppColors.textPrimary, fontWeight: FontWeight.w600)),
centerTitle: true, centerTitle: true,
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 17, height: 1.8, color: Color(0xFF333333))), child: Text(contents[type] ?? '内容加载中...', style: TextStyle(fontSize: 17, height: 1.8, color: AppColors.textPrimary)),
), ),
); );
} }
@@ -808,7 +886,7 @@ class DeviceManagementPage extends ConsumerWidget {
Container( Container(
width: 88, height: 88, width: 88, height: 88,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.iconBackground, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(28), borderRadius: BorderRadius.circular(28),
), ),
child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint), child: Icon(Icons.bluetooth_disabled, size: 44, color: AppColors.textHint),
@@ -826,9 +904,9 @@ class DeviceManagementPage extends ConsumerWidget {
icon: const Icon(Icons.add, size: 22), icon: const Icon(Icons.add, size: 22),
label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), label: const Text('添加设备', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.iconColor, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
), ),
), ),
), ),
@@ -845,15 +923,15 @@ class DeviceManagementPage extends ConsumerWidget {
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow, boxShadow: AppColors.cardShadow,
), ),
child: Column(children: [ child: Column(children: [
Container( Container(
width: 72, height: 72, width: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient, gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(AppTheme.rMd),
), ),
child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white), child: const Icon(Icons.bluetooth_connected, size: 36, color: Colors.white),
), ),
@@ -874,7 +952,7 @@ class DeviceManagementPage extends ConsumerWidget {
label: const Text('解绑', style: TextStyle(color: AppColors.error)), label: const Text('解绑', style: TextStyle(color: AppColors.error)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
side: const BorderSide(color: AppColors.error), side: const BorderSide(color: AppColors.error),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
), ),
@@ -886,9 +964,9 @@ class DeviceManagementPage extends ConsumerWidget {
icon: const Icon(Icons.swap_horiz, size: 18), icon: const Icon(Icons.swap_horiz, size: 18),
label: const Text('更换设备'), label: const Text('更换设备'),
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: AppColors.iconColor, backgroundColor: AppColors.primary,
foregroundColor: Colors.white, foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rMd)),
padding: const EdgeInsets.symmetric(vertical: 14), padding: const EdgeInsets.symmetric(vertical: 14),
), ),
), ),
@@ -905,7 +983,7 @@ class DeviceManagementPage extends ConsumerWidget {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.cardBackground, color: AppColors.cardBackground,
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(AppTheme.rMd),
boxShadow: AppColors.cardShadow, boxShadow: AppColors.cardShadow,
), ),
child: Column(children: [ child: Column(children: [
@@ -913,7 +991,7 @@ class DeviceManagementPage extends ConsumerWidget {
Container( Container(
width: 6, height: 18, width: 6, height: 18,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.purpleBlueGradient, gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
), ),
@@ -925,8 +1003,8 @@ class DeviceManagementPage extends ConsumerWidget {
Container( Container(
width: 56, height: 56, width: 56, height: 56,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.lightGradient, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rMd),
), ),
child: const Text('🩺', style: TextStyle(fontSize: 28)), child: const Text('🩺', style: TextStyle(fontSize: 28)),
), ),
@@ -938,7 +1016,7 @@ class DeviceManagementPage extends ConsumerWidget {
const Spacer(), const Spacer(),
if (device.lastReading!.pulse != null) if (device.lastReading!.pulse != null)
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.iconColor)), Text('${device.lastReading!.pulse}', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.primary)),
const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)), const Text('bpm', style: TextStyle(fontSize: 13, color: AppColors.textHint)),
]), ]),
]), ]),
@@ -952,12 +1030,12 @@ class DeviceManagementPage extends ConsumerWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.iconBackground, color: AppColors.iconBg,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rMd),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor), Icon(Icons.lightbulb_outline, size: 18, color: AppColors.primary),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), const Text('使用说明', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]), ]),
@@ -974,7 +1052,7 @@ class DeviceManagementPage extends ConsumerWidget {
Widget _guideItem(String num, String text) => Padding( Widget _guideItem(String num, String text) => Padding(
padding: const EdgeInsets.only(bottom: 6), padding: const EdgeInsets.only(bottom: 6),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.iconColor)), Text('$num.', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.primary)),
const SizedBox(width: 6), const SizedBox(width: 6),
Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))), Expanded(child: Text(text, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))),
]), ]),
@@ -995,61 +1073,8 @@ class DeviceManagementPage extends ConsumerWidget {
} }
} }
// ═══════════════════ 自定义滑动删除组件 ═══════════════════
class _SwipeDeleteTile extends StatefulWidget {
final Widget child;
final VoidCallback onDelete;
final VoidCallback onTap;
const _SwipeDeleteTile({super.key, required this.child, required this.onDelete, required this.onTap});
@override State<_SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
class _SwipeDeleteTileState extends State<_SwipeDeleteTile> with SingleTickerProviderStateMixin {
double _dx = 0;
static const _maxSlide = 80.0;
static const _threshold = 40.0;
void _onDragUpdate(DragUpdateDetails d) {
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
}
void _onDragEnd(DragEndDetails d) {
if (_dx < -_threshold) {
setState(() => _dx = -_maxSlide);
} else {
setState(() => _dx = 0);
}
}
void _onDelete() {
widget.onDelete();
setState(() => _dx = 0);
}
void _closeSwipe() => setState(() => _dx = 0);
@override Widget build(BuildContext context) {
return Stack(children: [
Positioned.fill(child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
decoration: BoxDecoration(color: AppTheme.error, borderRadius: BorderRadius.circular(16)),
child: const Align(
alignment: Alignment.centerRight,
child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: Colors.white, size: 28)),
),
)),
GestureDetector(
onTap: _dx < 0 ? () { widget.onDelete(); setState(() => _dx = 0); } : widget.onTap,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: Transform.translate(offset: Offset(_dx, 0), child: widget.child),
),
]);
}
}
Widget _empty(BuildContext context, String title, String subtitle) => Widget _empty(BuildContext context, String title, String subtitle) =>
Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.inbox_outlined, size: 64, color: Colors.grey[300]), Icon(Icons.inbox_outlined, size: 64, color: AppColors.textHint),
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium), const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
])); ]));

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
@@ -74,8 +75,8 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Widget _buildHeader(ReportAnalysis analysis) { Widget _buildHeader(ReportAnalysis analysis) {
return Container( return Container(
width: double.infinity, padding: const EdgeInsets.all(16), width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 8)]), boxShadow: [AppTheme.shadowCard]),
child: Row(children: [ child: Row(children: [
Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)),
child: const Icon(Icons.auto_awesome, size: 28, color: AppTheme.primary)), child: const Icon(Icons.auto_awesome, size: 28, color: AppTheme.primary)),
@@ -83,7 +84,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 16, color: Color(0xFF999999))), const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)),
])), ])),
]), ]),
); );
@@ -94,16 +95,16 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
return Container( return Container(
width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isReviewed ? const Color(0xFFDCFCE7) : const Color(0xFFFFF3E0), color: isReviewed ? AppColors.successLight : AppColors.warningLight,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(10),
), ),
child: Row(mainAxisSize: MainAxisSize.min, children: [ child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 21, Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 21,
color: isReviewed ? AppTheme.success : const Color(0xFFF59E0B)), color: isReviewed ? AppTheme.success : AppColors.warning),
const SizedBox(width: 8), const SizedBox(width: 8),
Text(isReviewed ? '已通过医生审核' : '等待医生审核中', Text(isReviewed ? '已通过医生审核' : '等待医生审核中',
style: TextStyle(fontSize: 17, fontWeight: FontWeight.w500, style: TextStyle(fontSize: 17, fontWeight: FontWeight.w500,
color: isReviewed ? AppTheme.success : const Color(0xFFF59E0B))), color: isReviewed ? AppTheme.success : AppColors.warning)),
]), ]),
); );
} }
@@ -111,7 +112,7 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Widget _buildIndicators(ReportAnalysis analysis) { Widget _buildIndicators(ReportAnalysis analysis) {
return Container( return Container(
width: double.infinity, width: double.infinity,
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg)),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Padding( const Padding(
padding: EdgeInsets.fromLTRB(16, 16, 16, 8), padding: EdgeInsets.fromLTRB(16, 16, 16, 8),
@@ -128,19 +129,19 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
final IconData icon; final IconData icon;
switch (ind.status) { switch (ind.status) {
case 'high': color = AppTheme.error; icon = Icons.arrow_upward; break; case 'high': color = AppTheme.error; icon = Icons.arrow_upward; break;
case 'low': color = const Color(0xFFF9A825); icon = Icons.arrow_downward; break; case 'low': color = AppColors.warning; icon = Icons.arrow_downward; break;
default: color = AppTheme.success; icon = Icons.check_circle; default: color = AppTheme.success; icon = Icons.check_circle;
} }
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))),
child: Row(children: [ child: Row(children: [
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(ind.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), Text(ind.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty) if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty)
Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 15, color: Color(0xFF999999))), Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
]), ]),
), ),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
@@ -155,23 +156,23 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Widget _buildSummary(ReportAnalysis analysis) { Widget _buildSummary(ReportAnalysis analysis) {
return Container( return Container(
width: double.infinity, padding: const EdgeInsets.all(16), width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFFEFCE8), borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(AppTheme.rLg),
border: Border.all(color: const Color(0xFFFDE68A))), border: Border.all(color: AppColors.warning.withAlpha(80))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [ Row(children: [
Text('💡', style: TextStyle(fontSize: 21)), const Text('💡', style: TextStyle(fontSize: 21)),
SizedBox(width: 8), const SizedBox(width: 8),
Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFFD97706))), Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.warning)),
]), ]),
const SizedBox(height: 10), const SizedBox(height: 10),
Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果', Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果',
style: const TextStyle(fontSize: 17, color: Color(0xFF92400E), height: 1.6)), style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)),
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
width: double.infinity, padding: const EdgeInsets.all(10), width: double.infinity, padding: const EdgeInsets.all(10),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准',
style: TextStyle(fontSize: 15, color: Color(0xFFD97706))), style: TextStyle(fontSize: 15, color: AppColors.warning)),
), ),
]), ]),
); );
@@ -180,22 +181,22 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Widget _buildDoctorReview(ReportItem report) { Widget _buildDoctorReview(ReportItem report) {
return Container( return Container(
width: double.infinity, padding: const EdgeInsets.all(16), width: double.infinity, padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: const Color(0xFFDCFCE7), borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(AppTheme.rLg),
border: Border.all(color: const Color(0xFF86EFAC))), border: Border.all(color: AppColors.success.withAlpha(80))),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Row(children: [ Row(children: [
Text('', style: TextStyle(fontSize: 21)), const Text('', style: TextStyle(fontSize: 21)),
SizedBox(width: 8), const SizedBox(width: 8),
Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: Color(0xFF2E7D32))), Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.success)),
]), ]),
if (report.doctorName != null) ...[ if (report.doctorName != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 16, color: Color(0xFF4CAF50))), Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)),
], ],
if (report.reviewedAt != null) ...[ if (report.reviewedAt != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}', Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}',
style: const TextStyle(fontSize: 15, color: Color(0xFF81C784))), style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))),
], ],
if (report.severity != null) ...[ if (report.severity != null) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
@@ -205,18 +206,18 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
const SizedBox(height: 10), const SizedBox(height: 10),
Container( Container(
width: double.infinity, padding: const EdgeInsets.all(12), width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: Text('💬 ${report.doctorComment!}', child: Text('💬 ${report.doctorComment!}',
style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), height: 1.5)), style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)),
), ),
], ],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
const SizedBox(height: 8), const SizedBox(height: 8),
Container( Container(
width: double.infinity, padding: const EdgeInsets.all(12), width: double.infinity, padding: const EdgeInsets.all(12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: Text('📝 ${report.doctorRecommendation!}', child: Text('📝 ${report.doctorRecommendation!}',
style: const TextStyle(fontSize: 17, color: Color(0xFF333333), height: 1.5)), style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)),
), ),
], ],
]), ]),
@@ -225,11 +226,11 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
Widget _severityBadge(String severity) { Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) { final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', AppTheme.success, const Color(0xFFDCFCE7)), 'Normal' => ('🟢 正常', AppTheme.success, AppColors.successLight),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)), 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight),
'Severe' => ('🟠 中度异常', const Color(0xFFFF7043), const Color(0xFFFEE2E2)), 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)), 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)), _ => (severity, AppColors.textHint, AppColors.borderLight),
}; };
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),

View File

@@ -6,6 +6,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart'; import 'package:file_picker/file_picker.dart';
import 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.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';
@@ -129,7 +130,9 @@ class ReportNotifier extends Notifier<ReportState> {
); );
}).toList(); }).toList();
state = state.copyWith(reports: reports); state = state.copyWith(reports: reports);
} catch (_) {} } catch (e) {
debugPrint('[Report] 加载报告列表失败: $e');
}
} }
void fetchReportDetail(String reportId) async { void fetchReportDetail(String reportId) async {
@@ -234,6 +237,7 @@ class ReportListPage extends ConsumerWidget {
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
title: const Text('看报告'), title: const Text('看报告'),
centerTitle: true, centerTitle: true,
), ),
@@ -311,7 +315,7 @@ class ReportListPage extends ConsumerWidget {
width: 120, width: 120,
height: 120, height: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F2FF), color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(60), borderRadius: BorderRadius.circular(60),
), ),
child: const Icon(Icons.file_open, size: 48, color: AppTheme.primaryLight), child: const Icon(Icons.file_open, size: 48, color: AppTheme.primaryLight),
@@ -319,7 +323,7 @@ class ReportListPage extends ConsumerWidget {
const SizedBox(height: 20), const SizedBox(height: 20),
const Text('暂无检查报告', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w500)), const Text('暂无检查报告', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w500)),
const SizedBox(height: 8), const SizedBox(height: 8),
const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 17, color: Color(0xFF999999))), const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 17, color: AppColors.textHint)),
]), ]),
); );
} }
@@ -328,24 +332,24 @@ class ReportListPage extends ConsumerWidget {
return Container( return Container(
margin: const EdgeInsets.only(bottom: 12), margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], boxShadow: [AppTheme.shadowCard],
), ),
child: ListTile( child: ListTile(
leading: Container( leading: Container(
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F2FF), color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: _getReportIcon(report.type), child: _getReportIcon(report.type),
), ),
title: Text(report.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), title: Text(report.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(report.type, style: TextStyle(fontSize: 17, color: Colors.grey[500])), Text(report.type, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 15, color: Colors.grey[400])), Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 15, color: AppColors.textHint)),
]), ]),
trailing: Row( trailing: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -354,38 +358,36 @@ class ReportListPage extends ConsumerWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFDCFCE7), color: AppColors.successLight,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: const Text('已审核', style: TextStyle(fontSize: 13, color: Color(0xFF43A047))), child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success)),
) )
else if (!report.hasAnalysis) else if (!report.hasAnalysis)
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFE3F2FD), color: AppTheme.primaryLight,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(AppTheme.rXs),
), ),
child: const Text('分析中', style: TextStyle(fontSize: 13, color: Color(0xFF2196F3))), child: Text('分析中', style: TextStyle(fontSize: 13, color: AppTheme.primary)),
) )
else if (report.status == 'PendingDoctor') else if (report.status == 'PendingDoctor')
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3E0), color: AppColors.warningLight,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(4),
), ),
child: const Text('待审核', style: TextStyle(fontSize: 13, color: Color(0xFFF59E0B))), child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning)),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
report.hasAnalysis report.hasAnalysis
? const Icon(Icons.check_circle, size: 23, color: Color(0xFF43A047)) ? Icon(Icons.check_circle, size: 23, color: AppColors.success)
: const Icon(Icons.arrow_forward_ios, size: 21, color: Color(0xFFCCCCCC)), : Icon(Icons.arrow_forward_ios, size: 21, color: AppColors.textHint),
], ],
), ),
onTap: () { onTap: () => pushRoute(ref, 'reportDetail', params: {'id': report.id}),
pushRoute(ref, 'reportDetail', params: {'id': report.id});
},
), ),
); );
} }
@@ -487,23 +489,23 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFDCFCE7), color: AppColors.successLight,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
border: Border.all(color: const Color(0xFF43A047).withAlpha(50)), border: Border.all(color: AppColors.success.withAlpha(50)),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
const Text('', style: TextStyle(fontSize: 23)), const Text('', style: TextStyle(fontSize: 23)),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF2E7D32))), const Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.success)),
]), ]),
if (report.doctorName != null) ...[ if (report.doctorName != null) ...[
const SizedBox(height: 4), const SizedBox(height: 4),
Text('审核医生:${report.doctorName}', style: const TextStyle(fontSize: 16, color: Color(0xFF4CAF50))), Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)),
], ],
if (report.reviewedAt != null) ...[ if (report.reviewedAt != null) ...[
const SizedBox(height: 2), const SizedBox(height: 2),
Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: const TextStyle(fontSize: 15, color: Color(0xFF81C784))), Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))),
], ],
if (report.severity != null) ...[ if (report.severity != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
@@ -515,10 +517,10 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rMd),
), ),
child: Text('💬 ${report.doctorComment!}', style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), height: 1.5)), child: Text('💬 ${report.doctorComment!}', style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)),
), ),
], ],
if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[
@@ -527,10 +529,10 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rMd),
), ),
child: Text('📝 ${report.doctorRecommendation!}', style: const TextStyle(fontSize: 17, color: Color(0xFF333333), height: 1.5)), child: Text('📝 ${report.doctorRecommendation!}', style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)),
), ),
], ],
]), ]),
@@ -539,15 +541,15 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
Widget _severityBadge(String severity) { Widget _severityBadge(String severity) {
final (label, color, bg) = switch (severity) { final (label, color, bg) = switch (severity) {
'Normal' => ('🟢 正常', const Color(0xFF43A047), const Color(0xFFDCFCE7)), 'Normal' => ('🟢 正常', AppColors.success, AppColors.successLight),
'Abnormal' => ('🟡 轻度异常', const Color(0xFFF59E0B), const Color(0xFFFFF3E0)), 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight),
'Severe' => ('🟠 中度异常', const Color(0xFFEF4444), const Color(0xFFFEE2E2)), 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight),
'Critical' => ('🔴 重度异常', const Color(0xFFDC2626), const Color(0xFFFEE2E2)), 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight),
_ => (severity, Colors.grey, Colors.grey.withAlpha(30)), _ => (severity, AppColors.textSecondary, AppColors.border),
}; };
return Container( return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rXs)),
child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: color)), child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: color)),
); );
} }
@@ -556,8 +558,8 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0F2FF), color: AppColors.primaryLight,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
@@ -566,7 +568,7 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600)), Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600)),
const SizedBox(height: 4), const SizedBox(height: 4),
const Text('AI 预解读结果', style: TextStyle(fontSize: 17, color: Color(0xFF666666))), Text('AI 预解读结果', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)),
]), ]),
]), ]),
]), ]),
@@ -576,9 +578,9 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
Widget _buildAnalysisSection(ReportAnalysis analysis) { Widget _buildAnalysisSection(ReportAnalysis analysis) {
return Container( return Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5), border: Border.all(color: AppColors.border, width: 1),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding( Padding(
@@ -593,18 +595,18 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFFF3E0), color: AppColors.warningLight,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(AppTheme.rXs),
border: Border.all(color: const Color(0xFFFFE0B2)), border: Border.all(color: AppColors.warning.withAlpha(80)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
const Icon(Icons.info_outline, size: 19, color: Color(0xFFE65100)), Icon(Icons.info_outline, size: 19, color: AppColors.warning),
const SizedBox(width: 6), const SizedBox(width: 6),
const Text( Text(
'AI 预解读 · 待医生确认', 'AI 预解读 · 待医生确认',
style: TextStyle(fontSize: 16, color: Color(0xFFE65100), fontWeight: FontWeight.w500), style: TextStyle(fontSize: 16, color: AppColors.warning, fontWeight: FontWeight.w500),
), ),
], ],
), ),
@@ -620,28 +622,28 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
IconData statusIcon; IconData statusIcon;
switch (ind.status) { switch (ind.status) {
case 'high': case 'high':
statusColor = const Color(0xFFE53935); statusColor = AppColors.error;
statusIcon = Icons.arrow_upward; statusIcon = Icons.arrow_upward;
break; break;
case 'low': case 'low':
statusColor = const Color(0xFFF9A825); statusColor = AppColors.warning;
statusIcon = Icons.arrow_downward; statusIcon = Icons.arrow_downward;
break; break;
default: default:
statusColor = const Color(0xFF43A047); statusColor = AppColors.success;
statusIcon = Icons.check_circle; statusIcon = Icons.check_circle;
} }
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 12), padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))), decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))),
child: Row(children: [ child: Row(children: [
Expanded( Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(ind.name, style: const TextStyle(fontSize: 17)), Text(ind.name, style: const TextStyle(fontSize: 17)),
if (ind.referenceRange != null) if (ind.referenceRange != null)
Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: Colors.grey[400])), Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)),
]), ]),
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
@@ -657,25 +659,25 @@ class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEF3C7), color: AppColors.warningLight,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(AppTheme.rLg),
), ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [ Row(children: [
const Text('💡', style: TextStyle(fontSize: 23)), const Text('💡', style: TextStyle(fontSize: 23)),
const SizedBox(width: 8), const SizedBox(width: 8),
const Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFFD97706))), Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.warning)),
]), ]),
const SizedBox(height: 12), const SizedBox(height: 12),
Text(analysis.summary, style: const TextStyle(fontSize: 17, color: Color(0xFF92400E), height: 1.6)), Text(analysis.summary, style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)),
const SizedBox(height: 12), const SizedBox(height: 12),
Container( Container(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppTheme.surface,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 16, color: Color(0xFFD97706))), child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 16, color: AppColors.warning)),
), ),
]), ]),
); );

View File

@@ -1,4 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart'; import '../../core/navigation_provider.dart';
@@ -55,10 +56,10 @@ class NotificationPrefsPage extends ConsumerWidget {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
leading: IconButton( leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1A1A1A)), icon: const Icon(Icons.arrow_back_ios_new, color: AppColors.textPrimary),
onPressed: () => popRoute(ref), onPressed: () => popRoute(ref),
), ),
title: const Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), title: Text('消息通知', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
centerTitle: true, centerTitle: true,
), ),
body: SingleChildScrollView( body: SingleChildScrollView(
@@ -80,8 +81,8 @@ class NotificationPrefsPage extends ConsumerWidget {
_SectionTitle(title: '通知类型'), _SectionTitle(title: '通知类型'),
_SwitchTile( _SwitchTile(
icon: Icons.medication_rounded, icon: Icons.medication_rounded,
iconBg: const Color(0xFFFFF3E0), iconBg: AppColors.errorLight,
iconColor: const Color(0xFFFF9800), iconColor: AppColors.error,
title: '用药提醒', title: '用药提醒',
subtitle: '服药时间到达时提醒您', subtitle: '服药时间到达时提醒您',
value: prefs['medication'] ?? true, value: prefs['medication'] ?? true,
@@ -89,7 +90,7 @@ class NotificationPrefsPage extends ConsumerWidget {
), ),
_SwitchTile( _SwitchTile(
icon: Icons.warning_amber_rounded, icon: Icons.warning_amber_rounded,
iconBg: const Color(0xFFFFEBEE), iconBg: AppColors.errorLight,
iconColor: AppTheme.error, iconColor: AppTheme.error,
title: '健康异常提醒', title: '健康异常提醒',
subtitle: '检测到数据异常时及时通知', subtitle: '检测到数据异常时及时通知',
@@ -98,8 +99,8 @@ class NotificationPrefsPage extends ConsumerWidget {
), ),
_SwitchTile( _SwitchTile(
icon: Icons.event_available_rounded, icon: Icons.event_available_rounded,
iconBg: const Color(0xFFE8F5E9), iconBg: AppColors.successLight,
iconColor: const Color(0xFF4CAF50), iconColor: AppColors.success,
title: '复查日期提醒', title: '复查日期提醒',
subtitle: '复查日前一天提醒您预约', subtitle: '复查日前一天提醒您预约',
value: prefs['followUp'] ?? true, value: prefs['followUp'] ?? true,
@@ -107,7 +108,7 @@ class NotificationPrefsPage extends ConsumerWidget {
), ),
_SwitchTile( _SwitchTile(
icon: Icons.smart_toy_outlined, icon: Icons.smart_toy_outlined,
iconBg: const Color(0xFFF3E5F5), iconBg: AppColors.iconBg,
iconColor: AppTheme.primary, iconColor: AppTheme.primary,
title: 'AI 回复通知', title: 'AI 回复通知',
subtitle: 'AI 助手回复时发送通知', subtitle: 'AI 助手回复时发送通知',
@@ -128,13 +129,13 @@ class NotificationPrefsPage extends ConsumerWidget {
Container( Container(
margin: const EdgeInsets.symmetric(horizontal: 4), margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [ child: Row(children: [
Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async { Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0)); final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked); if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked);
})), })),
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: Colors.grey[400]))), Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 19, color: AppTheme.textHint))),
Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async { Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0)); final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked); if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked);
@@ -158,7 +159,7 @@ class _SectionTitle extends StatelessWidget {
const _SectionTitle({required this.title}); const _SectionTitle({required this.title});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF999999)))); return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textSecondary)));
} }
} }
@@ -184,17 +185,17 @@ class _SwitchTile extends StatelessWidget {
return Container( return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rMd)),
child: Row(children: [ child: Row(children: [
if (icon != null) ...[ if (icon != null) ...[
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)), Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 23, color: iconColor)),
const SizedBox(width: 12), const SizedBox(width: 12),
], ],
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 18, color: Color(0xFF1A1A1A), fontWeight: FontWeight.w500)), Text(title, style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w500)),
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: Colors.grey[500])), if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 15, color: AppTheme.textSub)),
])), ])),
Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: const Color(0xFFC5BFFF)), Switch(value: value, onChanged: onChanged, activeThumbColor: AppTheme.primary, activeTrackColor: AppColors.primaryLight),
]), ]),
); );
} }
@@ -210,8 +211,8 @@ class _TimeButton extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector(onTap: onTap, child: Container( return GestureDetector(onTap: onTap, child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(10)), decoration: BoxDecoration(border: Border.all(color: AppColors.border), borderRadius: BorderRadius.circular(AppTheme.rSm)),
child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]), child: Column(children: [Text(label, style: TextStyle(fontSize: 14, color: AppTheme.textHint)), Text(time, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppTheme.primary))]),
)); ));
} }
} }

View File

@@ -1,6 +1,7 @@
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 'package:shadcn_ui/shadcn_ui.dart'; import 'package:shadcn_ui/shadcn_ui.dart';
import '../../core/app_colors.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';
@@ -10,15 +11,12 @@ class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key}); const SettingsPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) { @override Widget build(BuildContext context, WidgetRef ref) {
final theme = ShadTheme.of(context);
return Scaffold( return Scaffold(
backgroundColor: theme.colorScheme.background, backgroundColor: AppColors.background,
appBar: AppBar( appBar: AppBar(
backgroundColor: theme.colorScheme.card, backgroundColor: AppColors.cardBackground,
elevation: 0, leading: IconButton(icon: const Icon(LucideIcons.chevronLeft, color: AppColors.textPrimary), onPressed: () => popRoute(ref)),
leading: IconButton(icon: Icon(LucideIcons.chevronLeft, color: theme.colorScheme.foreground), onPressed: () => popRoute(ref)), title: const Text('设置', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
title: Text('设置', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: theme.colorScheme.foreground)),
centerTitle: true, centerTitle: true,
), ),
body: SafeArea(child: SingleChildScrollView( body: SafeArea(child: SingleChildScrollView(
@@ -26,10 +24,8 @@ class SettingsPage extends ConsumerWidget {
child: Column(children: [ child: Column(children: [
const SizedBox(height: AppTheme.sMd), const SizedBox(height: AppTheme.sMd),
AppMenuItem(icon: LucideIcons.bell, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')), AppMenuItem(icon: LucideIcons.bell, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
AppMenuItem(icon: LucideIcons.pill, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')), AppMenuItem(icon: LucideIcons.type, title: '字体大小', subtitle: '老年版', onTap: () {}),
AppMenuItem(icon: LucideIcons.database, title: '数据导出', onTap: () {}), AppMenuItem(icon: LucideIcons.sprayCan, title: '清除缓存', onTap: () {}),
AppMenuItem(icon: LucideIcons.type, title: '字体大小', trailing: 'v1.0.0', onTap: () {}),
AppMenuItem(icon: LucideIcons.sprayCan, title: '清除缓存', subtitle: '73.2 MB', onTap: () {}),
AppMenuItem(icon: LucideIcons.info, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})), AppMenuItem(icon: LucideIcons.info, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
AppMenuItem(icon: LucideIcons.shield, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})), AppMenuItem(icon: LucideIcons.shield, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
const SizedBox(height: 30), const SizedBox(height: 30),
@@ -40,10 +36,10 @@ class SettingsPage extends ConsumerWidget {
height: 50, height: 50,
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
border: Border.all(color: theme.colorScheme.destructive.withAlpha(80)), border: Border.all(color: AppColors.error.withAlpha(80)),
borderRadius: BorderRadius.circular(AppTheme.rPill), borderRadius: BorderRadius.circular(AppTheme.rPill),
), ),
child: Text('退出登录', style: TextStyle(fontSize: 19, color: theme.colorScheme.destructive, fontWeight: FontWeight.w500)), child: const Text('退出登录', style: TextStyle(fontSize: 19, color: AppColors.error, fontWeight: FontWeight.w500)),
), ),
), ),
]), ]),
@@ -52,16 +48,15 @@ class SettingsPage extends ConsumerWidget {
} }
Future<void> _logout(BuildContext context, WidgetRef ref) async { Future<void> _logout(BuildContext context, WidgetRef ref) async {
final theme = ShadTheme.of(context);
final ok = await showDialog<bool>( final ok = await showDialog<bool>(
context: context, context: context,
builder: (ctx) => AlertDialog( builder: (ctx) => AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(AppTheme.rXl)),
title: Text('退出登录', style: TextStyle(color: theme.colorScheme.foreground)), title: const Text('退出登录'),
content: Text('确定退出?', style: TextStyle(color: theme.colorScheme.mutedForeground)), content: const Text('确定退出?'),
actions: [ actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: Text('取消', style: TextStyle(color: theme.colorScheme.mutedForeground))), TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: Text('确定', style: TextStyle(color: theme.colorScheme.destructive))), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定', style: TextStyle(color: AppColors.error))),
], ],
), ),
); );

View File

@@ -84,7 +84,9 @@ class AuthNotifier extends Notifier<AuthState> {
), ),
); );
} }
} catch (_) {} } catch (e) {
print('[Auth]加载个人信息失败: $e');
}
} }
/// 发送验证码,返回 (error, devCode) /// 发送验证码,返回 (error, devCode)
@@ -131,7 +133,7 @@ class AuthNotifier extends Notifier<AuthState> {
final db = ref.read(localDbProvider); final db = ref.read(localDbProvider);
final refresh = await db.read('refresh_token'); final refresh = await db.read('refresh_token');
if (refresh != null) { if (refresh != null) {
try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (_) {} try { await api.post('/api/auth/logout', data: {'refreshToken': refresh}); } catch (e) { print('[Auth]登出请求失败: $e'); }
} }
await api.clearTokens(); await api.clearTokens();
state = const AuthState(isLoggedIn: false, isLoading: false); state = const AuthState(isLoggedIn: false, isLoading: false);

View File

@@ -82,6 +82,31 @@ class ChatNotifier extends Notifier<ChatState> {
void markNeedsRebuild() => state = state.copyWith(); void markNeedsRebuild() => state = state.copyWith();
/// 不可变消息操作方法(供 chat_messages_view 新版代码调用)
void confirmMessage(String id) {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == id);
if (i >= 0) { msgs[i].confirmed = true; state = state.copyWith(messages: msgs); }
ref.invalidate(medicationListProvider);
ref.invalidate(latestHealthProvider);
}
void startEditingField(String msgId, String fieldLabel) {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == msgId);
if (i >= 0) { msgs[i].metadata?['_editingField'] = fieldLabel; state = state.copyWith(messages: msgs); }
}
void finishEditingField(String msgId, String fieldLabel, String value) {
final msgs = state.messages.toList();
final i = msgs.indexWhere((m) => m.id == msgId);
if (i >= 0) {
if (value.isNotEmpty) msgs[i].metadata?[fieldLabel] = value;
msgs[i].metadata?.remove('_editingField');
state = state.copyWith(messages: msgs);
}
}
@override @override
ChatState build() { ChatState build() {
Future.microtask(() { Future.microtask(() {

View File

@@ -197,7 +197,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
doctorTitle: doc['title']?.toString() ?? '', doctorTitle: doc['title']?.toString() ?? '',
doctorDepartment: doc['department']?.toString() ?? '', doctorDepartment: doc['department']?.toString() ?? '',
); );
} catch (_) {} } catch (e) { print('[Consultation]请求失败: $e'); }
} }
Future<void> _loadMessages(String consultationId) async { Future<void> _loadMessages(String consultationId) async {
@@ -218,7 +218,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
if (msgs.isNotEmpty) { if (msgs.isNotEmpty) {
state = state.copyWith(messages: msgs); state = state.copyWith(messages: msgs);
} }
} catch (_) {} } catch (e) { print('[Consultation]请求失败: $e'); }
} }
Future<void> sendMessage(String text) async { Future<void> sendMessage(String text) async {
@@ -333,7 +333,7 @@ class ConsultationChatNotifier extends Notifier<ConsultationChatState> {
if (newMsgs.isNotEmpty) { if (newMsgs.isNotEmpty) {
state = state.copyWith(messages: [...state.messages, ...newMsgs]); state = state.copyWith(messages: [...state.messages, ...newMsgs]);
} }
} catch (_) {} } catch (e) { print('[Consultation]请求失败: $e'); }
} }
void stop() { void stop() {

View File

@@ -160,6 +160,6 @@ class OmronBleService {
now.month, now.day, now.month, now.day,
now.hour, now.minute, now.second, now.hour, now.minute, now.second,
]); ]);
} catch (_) {} } catch (e) { print('[BLE]读取数据失败: $e'); }
} }
} }

View File

@@ -23,7 +23,7 @@ class GradientBorderButton extends StatelessWidget {
return Container( return Container(
height: height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isSuccess ? AppColors.successButtonGradient : AppColors.buttonGradient, gradient: isSuccess ? AppColors.primaryGradient : AppColors.primaryGradient,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: AppColors.buttonShadow, boxShadow: AppColors.buttonShadow,
), ),
@@ -78,14 +78,14 @@ class CardActionButton extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.backgroundSecondary, color: AppColors.cardInner,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: AppColors.borderLight), border: Border.all(color: AppColors.borderLight),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 18, color: iconColor ?? AppColors.iconColor), Icon(icon, size: 18, color: iconColor ?? AppColors.primary),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
@@ -102,6 +102,63 @@ class CardActionButton extends StatelessWidget {
} }
} }
/// 滑动删除组件左滑露出红色删除背景达30%阈值锁定,点击确认删除
/// [margin] 控制卡片之间的间距red背景与child完全对齐
class SwipeDeleteTile extends StatefulWidget {
final Widget child;
final VoidCallback onDelete;
final VoidCallback? onTap;
final EdgeInsetsGeometry margin;
const SwipeDeleteTile({super.key, required this.child, required this.onDelete, this.onTap, this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4)});
@override State<SwipeDeleteTile> createState() => _SwipeDeleteTileState();
}
class _SwipeDeleteTileState extends State<SwipeDeleteTile> with SingleTickerProviderStateMixin {
double _dx = 0;
static const _maxSlide = 80.0;
static const _threshold = 24.0; // 30% of 80
void _onDragUpdate(DragUpdateDetails d) {
setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0));
}
void _onDragEnd(DragEndDetails d) {
if (_dx < -_threshold) {
setState(() => _dx = -_maxSlide);
} else {
setState(() => _dx = 0);
}
}
@override Widget build(BuildContext context) {
final isSwiped = _dx < 0;
return Padding(
padding: widget.margin,
child: Stack(children: [
Positioned.fill(child: Container(
decoration: BoxDecoration(color: AppColors.error, borderRadius: BorderRadius.circular(16)),
child: const Align(
alignment: Alignment.centerRight,
child: Padding(padding: EdgeInsets.only(right: 20), child: Icon(Icons.delete_outline, color: AppColors.textOnGradient, size: 28)),
),
)),
GestureDetector(
behavior: isSwiped ? HitTestBehavior.opaque : HitTestBehavior.deferToChild,
onTap: isSwiped ? () { widget.onDelete(); setState(() => _dx = 0); } : widget.onTap,
onHorizontalDragUpdate: _onDragUpdate,
onHorizontalDragEnd: _onDragEnd,
child: Transform.translate(
offset: Offset(_dx, 0),
child: isSwiped
? AbsorbPointer(absorbing: true, child: widget.child)
: widget.child,
),
),
]),
);
}
}
/// 功能区小图标容器 /// 功能区小图标容器
class IconBox extends StatelessWidget { class IconBox extends StatelessWidget {
final IconData icon; final IconData icon;
@@ -123,13 +180,13 @@ class IconBox extends StatelessWidget {
width: size, width: size,
height: size, height: size,
decoration: BoxDecoration( decoration: BoxDecoration(
color: backgroundColor ?? AppColors.iconBackground, color: backgroundColor ?? AppColors.iconBg,
borderRadius: BorderRadius.circular(size / 3), borderRadius: BorderRadius.circular(size / 3),
), ),
child: Icon( child: Icon(
icon, icon,
size: size * 0.55, size: size * 0.55,
color: iconColor ?? AppColors.iconColor, color: iconColor ?? AppColors.primary,
), ),
); );
} }

View File

@@ -1,13 +1,12 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../core/app_theme.dart'; import '../core/app_theme.dart';
import '../core/app_colors.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/navigation_provider.dart'; import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart'; import '../providers/auth_provider.dart';
import '../providers/data_providers.dart'; import '../providers/data_providers.dart';
import '../providers/chat_provider.dart';
import 'service_package_card.dart'; import 'service_package_card.dart';
/// 侧滑抽屉——彩色分区卡片式设计
class HealthDrawer extends ConsumerWidget { class HealthDrawer extends ConsumerWidget {
const HealthDrawer({super.key}); const HealthDrawer({super.key});
@@ -17,75 +16,160 @@ class HealthDrawer extends ConsumerWidget {
final user = auth.user; final user = auth.user;
final latestHealth = ref.watch(latestHealthProvider); final latestHealth = ref.watch(latestHealthProvider);
return Drawer( return Drawer(
width: MediaQuery.of(context).size.width * 0.82, width: MediaQuery.of(context).size.width * 0.85,
backgroundColor: const Color(0xFFFAFBFE), child: Container(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea( child: SafeArea(
child: ListView( child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), padding: const EdgeInsets.fromLTRB(14, 12, 14, 0),
children: [ children: [
// ════════════ 用户区 ════════════ // 个人信息 + 蓝牙 + 健康档案 — 合并为一个卡片
_SectionCard( _buildTopCard(user, ref),
color: const Color(0xFF635BFF), const SizedBox(height: 8),
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)], _buildHealthSection(latestHealth, ref),
const SizedBox(height: 8),
_buildFeatureSection(ref),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
boxShadow: [AppTheme.shadowCard],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 0),
child: Container(
decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppTheme.rPill),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: const Text('服务包', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
),
),
const ServicePackageCard(compact: true),
]),
),
const SizedBox(height: 8),
],
),
),
),
);
}
// ════════════ 顶部区域(个人信息 + 设置 + 快捷入口,无框融入背景) ═══════════
Widget _buildTopCard(dynamic user, WidgetRef ref) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(children: [
// 第一行:个人信息(窄)+ 设置按钮
Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 8, 0),
child: Row(children: [
// 用户头像+名称 — 占70%
Expanded(
flex: 7,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => pushRoute(ref, 'profile'), onTap: () => pushRoute(ref, 'profile'),
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(children: [ child: Row(children: [
Container( Container(
width: 52, height: 52, width: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]), color: Colors.white,
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: Colors.white30, width: 1.5),
), ),
child: user?.avatarUrl != null child: const Icon(Icons.person, size: 24, color: AppColors.textHint),
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
: _defaultAvatar(),
), ),
const SizedBox(width: 14), const SizedBox(width: 10),
Expanded(child: Column( Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
crossAxisAlignment: CrossAxisAlignment.start, Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary), overflow: TextOverflow.ellipsis),
children: [ Text(user?.phone ?? '未登录', style: const TextStyle(fontSize: 12, color: AppColors.textSecondary)),
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 20, fontWeight: FontWeight.w700, color: Colors.white)), ])),
]),
),
),
// 设置按钮 — 占30%
Expanded(
flex: 3,
child: GestureDetector(
onTap: () => pushRoute(ref, 'settings'),
child: Container(
height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.rSm),
),
child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [
const Icon(Icons.settings_outlined, size: 22, color: AppColors.textPrimary),
const SizedBox(height: 2), const SizedBox(height: 2),
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 15, color: Colors.white70)), const Text('设置', style: TextStyle(fontSize: 11, color: AppColors.textPrimary)),
],
)),
Icon(Icons.chevron_right, size: 21, color: Colors.white54),
]), ]),
), ),
), ),
), ),
]),
),
const SizedBox(height: 16),
// 第二行:蓝牙 + 健康档案 — 白色底
Row(children: [
Expanded(child: _QuickEntry(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))),
const SizedBox(width: 12),
Expanded(child: _QuickEntry(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))),
]),
]),
);
}
const SizedBox(height: 10), Widget _QuickEntry(IconData icon, String label, VoidCallback onTap) {
return GestureDetector(
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 18),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(AppTheme.rMd),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
),
child: Column(children: [
Icon(icon, size: 28, color: AppColors.primary),
const SizedBox(height: 6),
Text(label, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textPrimary)),
]),
),
);
}
// ════════════ 健康概览 ════════════ // ════════════ 健康概览 ════════════
_SectionCard( Widget _buildHealthSection(AsyncValue<Map<String, dynamic>> latestHealth, WidgetRef ref) {
return Container(
decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
gradientColors: null, borderRadius: BorderRadius.circular(AppTheme.rLg),
child: Column( border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
crossAxisAlignment: CrossAxisAlignment.start, boxShadow: [AppTheme.shadowCard],
children: [ ),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6), padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Row(children: [ child: Row(children: [
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppTheme.primary.withAlpha(15), gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(AppTheme.rPill),
), ),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: const Row(mainAxisSize: MainAxisSize.min, children: [ child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.monitor_heart_rounded, size: 16, color: AppTheme.primary), Icon(Icons.monitor_heart_rounded, size: 15, color: Colors.white),
SizedBox(width: 4), SizedBox(width: 4),
Text('健康概览', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppTheme.primary)), Text('健康概览', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
]), ]),
), ),
const Spacer(), const Spacer(),
GestureDetector( GestureDetector(
onTap: () => pushRoute(ref, 'trend'), onTap: () => pushRoute(ref, 'trend'),
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 14, color: Color(0xFF888888)))), child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 13, color: AppColors.textHint))),
), ),
]), ]),
), ),
@@ -93,57 +177,52 @@ class HealthDrawer extends ConsumerWidget {
data: (data) => Padding( data: (data) => Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
child: Row(children: [ child: Row(children: [
_MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})), _MiniMetric(Icons.favorite_rounded, '血压', _bpText(data['BloodPressure']), () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
_MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})), _MiniMetric(Icons.monitor_heart_outlined, '心率', _metricVal(data['HeartRate']), () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
_MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})), _MiniMetric(Icons.bloodtype_outlined, '血糖', _metricVal(data['Glucose']), () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
_MiniMetric(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})), _MiniMetric(Icons.air_outlined, '血氧', _metricVal(data['SpO2']), () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
_MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})), _MiniMetric(Icons.monitor_weight_outlined, '体重', _metricVal(data['Weight']), () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
]), ]),
), ),
loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppTheme.primary)))), loading: () => const Padding(padding: EdgeInsets.all(20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: AppColors.primary)))),
error: (_, __) => Padding( error: (_, __) => Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), padding: const EdgeInsets.fromLTRB(12, 4, 12, 14),
child: Row(children: [ child: Row(children: [
_MiniMetric(icon: Icons.favorite_rounded, label: '血压', value: '--'), _MiniMetric(Icons.favorite_rounded, '血压', '--'),
_MiniMetric(icon: Icons.monitor_heart_outlined, label: '心率', value: '--'), _MiniMetric(Icons.monitor_heart_outlined, '心率', '--'),
_MiniMetric(icon: Icons.bloodtype_outlined, label: '血糖', value: '--'), _MiniMetric(Icons.bloodtype_outlined, '血糖', '--'),
_MiniMetric(icon: Icons.air_outlined, label: '血氧', value: '--'), _MiniMetric(Icons.air_outlined, '血氧', '--'),
_MiniMetric(icon: Icons.monitor_weight_outlined, label: '体重', value: '--'), _MiniMetric(Icons.monitor_weight_outlined, '体重', '--'),
]), ]),
), ),
), ),
], ]),
), );
), }
const SizedBox(height: 10), // ════════════ 功能区 ════════════
Widget _buildFeatureSection(WidgetRef ref) {
// ════════════ 功能区(横向均布)════════════ return Container(
_SectionCard(
color: Colors.white,
gradientColors: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 6),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF0A060).withAlpha(15), color: Colors.white,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(AppTheme.rLg),
border: Border.all(color: Color(0xFFC8DDFD), width: 1.5),
boxShadow: [AppTheme.shadowCard],
), ),
child: const Row(mainAxisSize: MainAxisSize.min, children: [ child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.apps_rounded, size: 16, color: Color(0xFFF0A060)), Padding(
SizedBox(width: 4), padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
Text('功能', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))), child: Container(
]), decoration: BoxDecoration(
gradient: AppColors.primaryGradient,
borderRadius: BorderRadius.circular(AppTheme.rPill),
),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
child: const Text('功能', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)),
), ),
]),
), ),
Padding( Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 14), padding: const EdgeInsets.fromLTRB(12, 6, 12, 14),
child: Row(children: [ child: Row(children: [
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')), _FeatureChip(icon: Icons.description_outlined, label: '报告管理', onTap: () => pushRoute(ref, 'reports')),
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')), _FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', onTap: () => pushRoute(ref, 'calendar')),
@@ -151,151 +230,44 @@ class HealthDrawer extends ConsumerWidget {
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')), _FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', onTap: () => pushRoute(ref, 'followups')),
]), ]),
), ),
Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 14),
child: Row(children: [
_FeatureChip(icon: Icons.bluetooth_rounded, label: '蓝牙设备', onTap: () => pushRoute(ref, 'devices')),
]), ]),
),
],
),
),
const SizedBox(height: 10),
// ════════════ 产品服务包 ════════════
const ServicePackageCard(compact: true),
const SizedBox(height: 10),
// ════════════ 设置区 ════════════
_SectionCard(
color: const Color(0xFFF5F5F7),
gradientColors: null,
child: Material(
color: Colors.transparent,
child: InkWell(
onTap: () => pushRoute(ref, 'settings'),
borderRadius: BorderRadius.circular(16),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: const Color(0xFFEEEEEE),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.settings_outlined, size: 21, color: Color(0xFF666666)),
),
const SizedBox(width: 12),
const Expanded(child: Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500, color: Color(0xFF333333)))),
const Icon(Icons.chevron_right, size: 19, color: Color(0xFFCCCCCC)),
]),
),
),
),
),
const SizedBox(height: 6),
],
),
),
); );
} }
static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70); // ════════════ Helpers ════════════
String _bpText(dynamic bp) { String _bpText(dynamic bp) {
if (bp == null) return '--'; if (bp == null) return '--';
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}'; if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
return '--'; return '--';
} }
String _metricVal(dynamic val) {
String _metricVal(dynamic metric) { if (val == null) return '--';
if (metric == null) return '--'; if (val is num) return val.toStringAsFixed(val is double ? 1 : 0);
if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; } if (val is Map) return (val['value']?.toString() ?? '--');
return metric.toString(); return '--';
}
}
// ═══════════════════════════════════════════════════════════════
// 分区卡片容器 —— 带圆角、阴影和微动效
// ═══════════════════════════════════════════════════════════════
class _SectionCard extends StatelessWidget {
final Widget child;
final Color color;
final List<Color>? gradientColors;
const _SectionCard({required this.child, required this.color, this.gradientColors});
@override
Widget build(BuildContext context) {
return TweenAnimationBuilder<double>(
tween: Tween(begin: 0.0, end: 1.0),
duration: const Duration(milliseconds: 500),
curve: Curves.easeOutCubic,
builder: (context, value, child) => Transform.translate(
offset: Offset(0, 8 * (1 - value)),
child: Opacity(opacity: value, child: child),
),
child: Container(
decoration: BoxDecoration(
color: gradientColors == null ? color : null,
gradient: gradientColors != null ? LinearGradient(
colors: gradientColors!,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
) : null,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: (gradientColors?.first ?? color).withAlpha(25),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
clipBehavior: Clip.antiAlias,
child: child,
),
);
} }
} }
// ═══════════════════════════════════════════════════════════════
// 健康指标小方块
// ═══════════════════════════════════════════════════════════════
class _MiniMetric extends StatelessWidget { class _MiniMetric extends StatelessWidget {
final IconData icon; final IconData icon; final String label; final String value; final VoidCallback? onTap;
final String label; const _MiniMetric(this.icon, this.label, this.value, [this.onTap]);
final String value;
final VoidCallback? onTap;
const _MiniMetric({required this.icon, required this.label, required this.value, this.onTap});
static const _c = AppTheme.primary;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3), margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _c.withAlpha(10), gradient: AppColors.bgGradient,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 21, color: _c), Icon(icon, size: 24, color: AppColors.primary),
const SizedBox(height: 6), const SizedBox(height: 5),
Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: Color(0xFF1A1A1A)), maxLines: 1, overflow: TextOverflow.ellipsis), Text(value, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: AppColors.textPrimary)),
const SizedBox(height: 2), Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textHint)),
Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500])),
]), ]),
), ),
), ),
@@ -303,43 +275,28 @@ class _MiniMetric extends StatelessWidget {
} }
} }
// ═══════════════════════════════════════════════════════════════
// 功能按钮(横向)
// ═══════════════════════════════════════════════════════════════
class _FeatureChip extends StatelessWidget { class _FeatureChip extends StatelessWidget {
final IconData icon; final IconData icon; final String label; final VoidCallback onTap;
final String label; const _FeatureChip({required this.icon, required this.label, required this.onTap});
final VoidCallback onTap;
const _FeatureChip({
required this.icon,
required this.label,
required this.onTap,
});
static const _c = AppTheme.primary;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Expanded( return Expanded(
child: GestureDetector( child: GestureDetector(
onTap: onTap, onTap: onTap,
child: Container( child: Container(
margin: const EdgeInsets.symmetric(horizontal: 3), margin: const EdgeInsets.symmetric(horizontal: 2),
padding: const EdgeInsets.symmetric(vertical: 10), padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _c.withAlpha(10), gradient: AppColors.bgGradient,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(AppTheme.rSm),
), ),
child: Column(mainAxisSize: MainAxisSize.min, children: [ child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 21, color: _c), Icon(icon, size: 22, color: AppColors.primary),
const SizedBox(height: 6), const SizedBox(height: 4),
Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Color(0xFF666666))), Text(label, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: AppColors.textPrimary)),
]), ]),
), ),
), ),
); );
} }
} }

View File

@@ -145,6 +145,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "0.7.12" version: "0.7.12"
dev_build:
dependency: transitive
description:
name: dev_build
sha256: f2742f154e484a52471dcc7eda17e6da06ab8b1d0a64b4845710d5de4fa9cc47
url: "https://pub.dev"
source: hosted
version: "1.1.7+4"
dio: dio:
dependency: "direct main" dependency: "direct main"
description: description:
@@ -653,6 +661,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.0.0" version: "2.0.0"
native_toolchain_c:
dependency: transitive
description:
name: native_toolchain_c
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
url: "https://pub.dev"
source: hosted
version: "0.19.1"
node_preamble: node_preamble:
dependency: transitive dependency: transitive
description: description:
@@ -821,6 +837,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.5.2" version: "1.5.2"
process_run:
dependency: transitive
description:
name: process_run
sha256: e0e24c11a49445c449911daef46cf28d68e80a5e33daafbfbc6d6819a1f83519
url: "https://pub.dev"
source: hosted
version: "1.3.3+1"
pub_semver: pub_semver:
dependency: transitive dependency: transitive
description: description:
@@ -978,6 +1002,22 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.5.8" version: "2.5.8"
sqflite_common_ffi:
dependency: transitive
description:
name: sqflite_common_ffi
sha256: cd0c7f7de39a08f2d54ef144d9058c46eca8461879aaa648025643455c1e5a20
url: "https://pub.dev"
source: hosted
version: "2.4.0+3"
sqflite_common_ffi_web:
dependency: "direct main"
description:
name: sqflite_common_ffi_web
sha256: "79338d0b69521d70cea10f841209ac87ce617921aaf7d33e7380682c83da1f06"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
sqflite_darwin: sqflite_darwin:
dependency: transitive dependency: transitive
description: description:
@@ -994,6 +1034,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "2.4.0" version: "2.4.0"
sqlite3:
dependency: transitive
description:
name: sqlite3
sha256: "9488c7d2cdb1091c91cacf7e207cff81b28bff8e366f042bad3afe7d34afe189"
url: "https://pub.dev"
source: hosted
version: "3.3.2"
sse: sse:
dependency: transitive dependency: transitive
description: description:

View File

@@ -20,6 +20,7 @@ dependencies:
# 本地数据库 # 本地数据库
sqflite: ^2.4.0 sqflite: ^2.4.0
sqflite_common_ffi_web: ^1.1.1
path: ^1.9.0 path: ^1.9.0
# 图表 # 图表

View File

@@ -6,7 +6,8 @@ import 'package:health_app/pages/auth/login_page.dart';
void main() { void main() {
testWidgets('主题颜色正确', (tester) async { testWidgets('主题颜色正确', (tester) async {
expect(AppTheme.primary, AppTheme.primaryLight); expect(AppTheme.primary, const Color(0xFF6366F1));
expect(AppTheme.primaryLight, const Color(0xFFEEF2FF));
expect(AppTheme.bg, AppTheme.bg); expect(AppTheme.bg, AppTheme.bg);
expect(AppTheme.error, const Color(0xFFF56C6C)); expect(AppTheme.error, const Color(0xFFF56C6C));
expect(AppTheme.success, const Color(0xFF6ECF8A)); expect(AppTheme.success, const Color(0xFF6ECF8A));