Compare commits

...

24 Commits

Author SHA1 Message Date
MingNian
5bd0155e17 feat: 侧边栏重设计 - 彩色分区卡片+动画入场 2026-06-03 21:29:47 +08:00
MingNian
f6c1ea7ec9 style: 淡薰紫 Lavender Breeze + UI修复
- 主色改淡薰紫 #8B9CF7 + 白底 清新风格
- 每个智能体卡不同淡色调
- 删除欢迎卡底部"或直接对我说"
- 运动创建/打卡接入 API
- 全项目薄荷绿→淡紫替换
2026-06-03 20:46:39 +08:00
MingNian
f46c30f8e7 test: 更新 DefaultPrompt 测试(已移除阿福) 2026-06-03 20:33:24 +08:00
MingNian
8dcf99cac5 style: 全项目紫色→薄荷绿 Fresh Air 清新风
- 主色 #635BFF→#14B8A6 (薄荷绿)
- 浅紫 #EDEBFF→#E6FAF6 (极浅薄荷)
- 深紫 #4B44D6→#0F9D8E (深薄荷)
- 渐变紫→薄荷渐变
- 全局13种紫色映射替换
2026-06-03 20:30:28 +08:00
MingNian
f484c6b66a style: 主题升级 — 暖茶绿 Soft Clinical
- 主色: 深青碧 #0E8C7D(专业信赖)
- 背景: 暖白 #FAF6F1(不再冰冷)
- 高亮: 琥珀 #F0984A(温暖人性)
- 全局圆角统一 14-26px
- 字体层级优化
2026-06-03 20:28:39 +08:00
MingNian
7cd79bce68 fix: 删除重复的 exerciseServiceProvider 定义 2026-06-03 20:26:33 +08:00
MingNian
2cb1cf4a9c fix: 方法移入 class 内部 + 删重复代码 2026-06-03 20:23:59 +08:00
MingNian
ff96fb6c4c fix: 补全运动服务 Provider
- 添加 exerciseServiceProvider(之前缺失导致运动页加载失败)
2026-06-03 20:20:34 +08:00
MingNian
ea7226c805 fix: 任务卡片移入对话流 + 综合信息卡片分组修复
- 新增 taskCard 消息类型,作为对话第一条消息
- 今日任务卡片从独立区域移至聊天流内
- AgentWelcomeCard 从 metadata 读 agent 不再全局共享
- 切换胶囊不会影响已发过的卡片
2026-06-03 20:19:15 +08:00
MingNian
15f9a122ca fix: 用药编辑字段名修正 - 黑屏修复
- times→timeOfDay, start_date→startDate, end_date→endDate
- 去掉不存在的weekday字段
- 添加source=Manual
- frequency固定传Daily
2026-06-03 20:05:45 +08:00
MingNian
e3b9716f7c fix: 图片发送/医生加载/运动超时/用药黑屏/服药打卡
- sendImage: 本地预览→上传→远程URL替换
- doctorListProvider: 8s超时+mock医生fallback
- currentExercisePlanProvider: 8s超时→显示空状态
- 用药编辑: try-catch防黑屏+刷新列表
- 服药打卡: 接入后端confirm()接口
2026-06-03 20:03:17 +08:00
MingNian
95bf5732f6 fix: 胶囊点击始终显示欢迎卡片 + 移除中间面板
- 每次点胶囊都插入 AgentWelcomeCard(去掉 Set 去重限制)
- 移除胶囊和输入框之间的紧凑操作面板
2026-06-03 16:22:09 +08:00
MingNian
711b583aaf fix: 7处修复 - 溢出/黑屏/趋势图/欢迎卡片/抽屉 2026-06-03 16:14:20 +08:00
MingNian
7953cca15d fix: 恢复医生列表/问诊入口
患者端需要:
- 医生列表(找医生)
- 问诊对话页
- 医生用Web端回复,患者用App发起
2026-06-03 15:13:25 +08:00
MingNian
07ddf2577a feat: 用药提醒功能 + 移除医生相关页面
- 后端新增 GET /api/medications/reminders 接口
- 前端任务卡片区显示真实用药提醒
- 移除 DoctorListPage/DoctorChatPage 路由
- 移除"找医生"面板按钮
- 医生端另做 Web 页面
2026-06-03 15:11:12 +08:00
MingNian
0e49b9a952 fix: 侧边栏新增功能入口(报告/日历/饮食/复查)
健康概览和历史对话之间增加:
- 报告管理 → reports 路由
- 健康日历 → calendar 路由
- 饮食记录 → dietRecords 路由
- 复查随访 → followups 路由
2026-06-03 14:33:25 +08:00
MingNian
ed716654b3 fix: 修复路由映射和入口连接
- profile 路由从 ProfileDetailPage 改为 ProfilePage
- profileEdit 路由改为 ProfileDetailPage
- 新增 devices 路由(DeviceManagementPage)
- 设置页隐私协议/关于按钮接线 staticText
- 设置页用药提醒跳转 medications
- 个人中心健康档案修正为 healthArchive,新增设备管理入口
2026-06-03 14:30:38 +08:00
MingNian
9fb60cb3cf feat: 聊天卡片升级+趋势图重写+智能体欢迎卡片
- AgentWelcomeCard:紫色渐变头部+快捷按钮网格+智能体描述
- DataConfirmCard:绿色渐变确认条+迷你趋势图+编辑/确认按钮
- MedicationConfirmCard:药丸图标+剩余药量进度条+确认/跳过
- DietAnalysisCard:大号热量+营养素圆环+食物明细+AI建议
- ReportAnalysisCard:指标表格+异常高亮+AI解读
- trend_page 重写:CustomPaint 平滑曲线+当前值卡片+统计摘要
- chat_provider 新增 agentWelcome 消息类型
2026-06-03 14:25:48 +08:00
MingNian
36ad334643 fix: 首页UI修复 - 底部溢出/胶囊/折叠/抽屉/screen适配
- 底部溢出:移除手动 viewInsets,让 Scaffold 默认处理键盘
- 智能体胶囊:新增常驻选择条,6个胶囊始终可见
- 任务卡片:双向切换,折叠后显示"点击展开"条
- 侧边栏:去掉固定高度,自适应内容
- K70适配:头像/字号/padding 全面紧凑化
2026-06-03 13:51:51 +08:00
MingNian
7b898f8660 chore: 清理测试文件和上传目录 2026-06-03 11:19:16 +08:00
MingNian
78573eaa5f fix: VLM 参数优化 - temperature 0.7, top_p 0.8, 指令放 system+user
- VisionAsync 新增 Temperature=0.7, TopP=0.8
- system prompt 用专业营养识别指令
- userText 用简短"请看图识别食物"配合图片
- 修复重复 prompt 导致 VLM 误读文本的 bug
2026-06-03 11:12:06 +08:00
MingNian
c6395ea9b4 feat: 全功能前后端联调完成,47/47 测试通过
前端:
- 新增 DietCapturePage 独立拍照识别页
- 5种消息卡片类型完整实现(数据确认/用药/饮食/报告/快捷选项)
- 任务卡片区:异常警告+数据摘要+自动折叠
- 侧滑抽屉:历史对话列表+对话管理
- 运动计划:进度卡片+创建计划+每日打卡
- 报告页:拍照/相册/PDF上传+分析
- 面板按钮补全血氧/体重录入
- UI 升级:紫色主题+动画+气泡样式
- 全部迁移 Riverpod 3.x API

后端:
- 新增 _UpdateMessageTypeAndMetadata,Tool Calling 自动映射消息类型
- SSE answer 事件携带 type 字段
- 提示词优化(移除"阿福",语气规则归位)
- 运动计划支持 AI 创建和打卡

测试:
- 新增 full_e2e_test.py 全流程测试(认证/数据CRUD/6个Agent对话/VLM/报告)
2026-06-02 20:31:22 +08:00
MingNian
498708e568 fix: 修复 Flutter 前端多项功能 + 后端运动计划 API
- Android 添加相机/存储权限,拍照和相册功能可用
- AI 回复支持 Markdown 渲染(加粗/表格不再显示**乱码)
- 附件按钮接线,支持拍照/相册/文件选择
- 智能体面板按钮全部接线(拍照/上传/手动录入/导航)
- 侧边栏 AI 录入后自动刷新健康数据
- 运动计划页增加创建按钮 + 打卡功能
- 后端运动计划支持 AI 创建和打卡(Tool Calling)
- 修复 CreateExercisePlanRequest JSON 反序列化
2026-06-02 16:34:36 +08:00
MingNian
df263baa5d chore: 回退至稳定版本,清理测试文件
- 回退 VLM prompt 至稳定的通用食物识别版本
- 保持 VisionClient 重命名和 VLM_* 配置键
- 清理所有测试图片和临时文件
2026-06-02 15:15:34 +08:00
101 changed files with 6292 additions and 749 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

View File

@@ -124,6 +124,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
var request = new ChatCompletionRequest
{
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
Temperature = 0.7f, TopP = 0.8f,
};
var json = JsonSerializer.Serialize(request, _jsonOptions);

View File

@@ -164,6 +164,7 @@ public sealed class ChatCompletionRequest
public bool Stream { get; set; }
public int MaxTokens { get; set; } = 2048;
public float Temperature { get; set; } = 0.7f;
public float? TopP { get; set; }
public List<ToolDefinition>? Tools { get; set; }
public string? ToolChoice { get; set; }
}

View File

@@ -21,8 +21,7 @@ public sealed class PromptManager
};
private const string DefaultPrompt = """
AI "阿福"
怀
AI健康管家
1.
@@ -34,6 +33,7 @@ public sealed class PromptManager
-
-
- /
- 怀
""";
private const string ConsultationPrompt = """

View File

@@ -107,6 +107,8 @@ public static class AiChatEndpoints
var maxIterations = 5;
var fullResponse = "";
var completedNormally = false;
var messageType = "text";
var metadata = new Dictionary<string, object>();
for (int i = 0; i < maxIterations; i++)
{
@@ -129,7 +131,7 @@ public static class AiChatEndpoints
if (!string.IsNullOrEmpty(content))
{
fullResponse += content;
await SseWriteAsync(http, new { action = "answer", data = content }, ct);
await SseWriteAsync(http, new { action = "answer", data = content, type = messageType }, ct);
}
}
catch (JsonException) { /* 跳过解析失败的 chunk */ }
@@ -160,6 +162,8 @@ public static class AiChatEndpoints
}
await SseWriteAsync(http, new { action = "tool_result", tool = tc.Function.Name, data = toolResult }, ct);
_UpdateMessageTypeAndMetadata(tc.Function.Name, toolResult, ref messageType, ref metadata);
messages.Add(new ChatMessage { Role = "tool", Content = JsonSerializer.Serialize(toolResult, JsonOpts), ToolCallId = tc.Id });
}
}
@@ -232,7 +236,7 @@ public static class AiChatEndpoints
// VLM 食物识别
app.MapPost("/api/ai/analyze-food-image", async (
HttpRequest httpRequest, HttpContext http,
QwenVisionClient visionClient, AppDbContext db,
VisionClient visionClient, AppDbContext db,
CancellationToken ct) =>
{
var userId = GetUserId(http);
@@ -242,8 +246,6 @@ public static class AiChatEndpoints
var files = form.Files.GetFiles("images");
if (files == null || files.Count == 0)
return Results.Ok(new { code = 40001, data = (object?)null, message = "请上传至少一张图片" });
if (files.Count > 8)
return Results.Ok(new { code = 40001, data = (object?)null, message = "一次最多上传 8 张图片" });
var imageUrls = new List<string>();
var uploadsDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads");
@@ -265,32 +267,17 @@ public static class AiChatEndpoints
// 压缩图片后转 base64VLM API 有请求体大小限制)
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 92L);
CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L);
var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
var base64 = Convert.ToBase64String(compressedBytes);
imageUrls.Add($"data:image/jpeg;base64,{base64}");
}
var prompt = """
1.
2.
3.
JSON
{
"foods": [
{"name":"食物名","portion":"份量描述","calories":,"proteinGrams":,"carbsGrams":,"fatGrams":}
],
"totalCalories":
}
""";
var prompt = "精准识别用户提供的食物图片,提取并返回详细信息,包括但不限于食物名称、具体份量及对应热量值。系统应确保识别结果的准确性和清晰度,以便为病人的饮食管理提供可靠数据支持。";
try
{
var response = await visionClient.VisionAsync(prompt, imageUrls, ct: ct);
var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct);
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
return Results.Ok(new { code = 0, data = result, message = (string?)null });
}
@@ -482,13 +469,44 @@ public static class AiChatEndpoints
private static async Task<object> ExecuteManageExercise(AppDbContext db, Guid userId, JsonElement args)
{
var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query";
if (action != "query") return new { success = false, message = "运动计划管理暂未实现" };
switch (action)
{
case "create":
var weekStart = args.TryGetProperty("week_start_date", out var wsd) ? DateOnly.Parse(wsd.GetString()!) : DateOnly.FromDateTime(DateTime.Now);
var plan = new ExercisePlan { Id = Guid.NewGuid(), UserId = userId, WeekStartDate = weekStart };
if (args.TryGetProperty("items", out var items))
{
foreach (var item in items.EnumerateArray())
{
plan.Items.Add(new ExercisePlanItem
{
Id = Guid.NewGuid(), DayOfWeek = item.GetProperty("day_of_week").GetInt32(),
ExerciseType = item.GetProperty("exercise_type").GetString() ?? "散步",
DurationMinutes = item.GetProperty("duration_minutes").GetInt32(),
IsRestDay = item.TryGetProperty("is_rest_day", out var rd) && rd.GetBoolean(),
});
}
}
db.ExercisePlans.Add(plan);
await db.SaveChangesAsync();
return new { success = true, plan_id = plan.Id };
var plan = await db.ExercisePlans.Where(p => p.UserId == userId)
case "checkin":
var itemId = args.TryGetProperty("item_id", out var iid) ? iid.GetGuid() : Guid.Empty;
var exerciseItem = await db.ExercisePlanItems.FindAsync([itemId]);
if (exerciseItem == null) return new { success = false, message = "条目不存在" };
exerciseItem.IsCompleted = true;
exerciseItem.CompletedAt = DateTime.UtcNow;
await db.SaveChangesAsync();
return new { success = true };
default: // query
var existingPlan = await db.ExercisePlans.Where(p => p.UserId == userId)
.OrderByDescending(p => p.WeekStartDate).FirstOrDefaultAsync();
if (plan == null) return new { found = false };
var items = await db.ExercisePlanItems.Where(i => i.PlanId == plan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = plan.Id, items = items.Select(i => new { i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
if (existingPlan == null) return new { found = false };
var exerciseItems = await db.ExercisePlanItems.Where(i => i.PlanId == existingPlan.Id).OrderBy(i => i.DayOfWeek).ToListAsync();
return new { found = true, plan_id = existingPlan.Id, items = exerciseItems.Select(i => new { i.Id, i.DayOfWeek, i.ExerciseType, i.DurationMinutes, i.IsCompleted }) };
}
}
private static async Task<string> BuildPatientContext(AppDbContext db, Guid userId, CancellationToken ct)
@@ -578,6 +596,40 @@ public static class AiChatEndpoints
}
};
/// <summary>根据工具调用结果更新消息类型和元数据</summary>
private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary<string, object> metadata)
{
switch (toolName)
{
case "record_health_data":
messageType = "data_confirm";
if (toolResult is IDictionary<string, object> resultDict)
{
if (resultDict.TryGetValue("type", out var type))
metadata["type"] = type.ToString();
if (resultDict.TryGetValue("success", out var success) && success is bool b && b)
metadata["success"] = true;
}
break;
case "manage_medication":
messageType = "medication_confirm";
if (toolResult is IDictionary<string, object> medDict)
{
if (medDict.TryGetValue("name", out var name))
metadata["name"] = name.ToString();
if (medDict.TryGetValue("dosage", out var dosage))
metadata["dosage"] = dosage.ToString();
}
break;
case "estimate_food_text":
messageType = "diet_analysis";
break;
case "analyze_report":
messageType = "report_analysis";
break;
}
}
/// <summary>压缩图片到合理大小供 VLM API 使用</summary>
private static void CompressImage(string inputPath, string outputPath, int maxWidth, long quality)
{

View File

@@ -101,6 +101,31 @@ public static class RemainingEndpoints
await db.SaveChangesAsync(ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
});
// 获取待提醒的用药
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
{
var userId = GetUserId(http);
var now = TimeOnly.FromDateTime(DateTime.Now);
var windowEnd = now.AddHours(1);
var meds = await db.Medications
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
.ToListAsync(ct);
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
// 检查今天是否已打卡
var today = DateOnly.FromDateTime(DateTime.Now);
var dueMeds = new List<object>();
foreach (var m in due)
{
var logged = await db.MedicationLogs.AnyAsync(l =>
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
if (!logged)
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
}
return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null });
});
}
public static void MapReportEndpoints(this WebApplication app)
@@ -271,5 +296,15 @@ public sealed record CreateMedicationRequest(string Name, string? Dosage, Medica
public sealed record CreateConsultationRequest(Guid DoctorId);
public sealed record SendMessageRequest(string Content);
public sealed record CreateExercisePlanRequest(DateOnly WeekStartDate, List<ExerciseItemDto>? Items);
public sealed record ExerciseItemDto(int DayOfWeek, string ExerciseType, int DurationMinutes, bool IsRestDay);
public sealed class CreateExercisePlanRequest
{
public DateOnly WeekStartDate { get; init; }
public List<ExerciseItemDto>? Items { get; init; }
}
public sealed class ExerciseItemDto
{
public int DayOfWeek { get; init; }
public string ExerciseType { get; init; } = "";
public int DurationMinutes { get; init; }
public bool IsRestDay { get; init; }
}

View File

@@ -73,10 +73,10 @@ builder.Services.AddHttpClient<DeepSeekClient>(client =>
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["DEEPSEEK_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60);
});
builder.Services.AddHttpClient<QwenVisionClient>(client =>
builder.Services.AddHttpClient<VisionClient>(client =>
{
client.BaseAddress = new Uri((builder.Configuration["QWEN_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["QWEN_API_KEY"] ?? "");
client.BaseAddress = new Uri((builder.Configuration["VLM_BASE_URL"] ?? "https://dashscope.aliyuncs.com/compatible-mode/v1").TrimEnd('/') + "/");
client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", builder.Configuration["VLM_API_KEY"] ?? "");
client.Timeout = TimeSpan.FromSeconds(60);
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

View File

@@ -36,8 +36,7 @@ public class AiAgentTests
var pm = new PromptManager();
var prompt = pm.GetSystemPrompt(AgentType.Default);
Assert.Contains("心脏", prompt);
Assert.Contains("阿福", prompt);
Assert.Contains("温暖", prompt);
Assert.Contains("健康", prompt);
}
[Fact]

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 112 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 231 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 MiB

View File

@@ -1,4 +1,8 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.CAMERA"/>
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE"/>
<uses-permission android:name="android.permission.READ_MEDIA_IMAGES"/>
<uses-feature android:name="android.hardware.camera" android:required="false"/>
<application
android:label="health_app"
android:name="${applicationName}"

BIN
health_app/flutter_01.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 KiB

BIN
health_app/flutter_02.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 558 KiB

View File

@@ -10,11 +10,11 @@ class HealthApp extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return const MaterialApp(
return MaterialApp(
title: '健康管家',
debugShowCheckedModeBanner: false,
theme: AppTheme.lightTheme,
home: _RootNavigator(),
home: const _RootNavigator(),
);
}
}

View File

@@ -1,3 +1,4 @@
import 'dart:io';
import 'package:dio/dio.dart';
import 'local_database.dart';
@@ -54,6 +55,19 @@ class ApiClient {
Future<Response> delete(String path) async {
return _dio.delete(path);
}
/// 上传文件multipart返回文件 URL
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
final formData = FormData.fromMap({
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
});
final res = await _dio.post(path, data: formData);
final data = res.data;
if (data is Map) {
return data['url']?.toString() ?? data['data']?['url']?.toString();
}
return null;
}
}
/// 认证拦截器:自动注入 token + 401 刷新

View File

@@ -4,10 +4,15 @@ import '../pages/auth/login_page.dart';
import '../pages/home/home_page.dart';
import '../pages/chart/trend_page.dart';
import '../pages/medication/medication_list_page.dart';
import '../pages/medication/medication_edit_page.dart';
import '../pages/report/report_pages.dart';
import '../pages/report/ai_analysis_page.dart';
import '../pages/consultation/consultation_pages.dart';
import '../pages/settings/settings_pages.dart';
import '../pages/settings/notification_prefs_page.dart';
import '../pages/profile/profile_page.dart';
import '../pages/profile/profile_detail_page.dart';
import '../pages/diet/diet_capture_page.dart';
import '../pages/remaining_pages.dart';
/// 根据路由信息返回对应页面
@@ -24,14 +29,14 @@ Widget buildPage(RouteInfo route) {
return const HealthCalendarPage();
case 'medications':
return const MedicationListPage();
case 'medicationAdd':
return const MedicationEditPage();
case 'medicationEdit':
return MedicationEditPage(id: params['id']);
return const MedicationEditPage();
case 'reports':
return const ReportListPage();
case 'reportDetail':
return ReportDetailPage(id: params['id']!);
case 'aiAnalysis':
return const AiAnalysisPage();
case 'doctors':
return const DoctorListPage();
case 'consultation':
@@ -40,10 +45,16 @@ Widget buildPage(RouteInfo route) {
return const ExercisePlanPage();
case 'dietRecords':
return const DietRecordListPage();
case 'dietCapture':
return const DietCapturePage();
case 'profile':
return const ProfilePage();
case 'profileEdit':
return const ProfileDetailPage();
case 'editProfile':
return const EditProfilePage();
case 'devices':
return const DeviceManagementPage();
case 'healthArchive':
return const HealthArchivePage();
case 'followups':

View File

@@ -1,77 +1,81 @@
import 'package:flutter/material.dart';
/// 健康管家主题配置——薰衣草紫 + 温暖治愈
/// 健康管家 — Lavender Breeze 淡紫清
class AppTheme {
AppTheme._();
static const Color primaryColor = Color(0xFF635BFF);
static const Color primaryLight = Color(0xFFEDEBFF);
static const Color primaryDark = Color(0xFF4B44D6);
static const Color background = Color(0xFFF8F9FF);
static const Color cardWhite = Color(0xFFFFFFFF);
static const Color textPrimary = Color(0xFF1A1A1A);
static const Color textSecondary = Color(0xFF666666);
static const Color textPlaceholder = Color(0xFF999999);
static const Color successGreen = Color(0xFF43A047);
static const Color errorRed = Color(0xFFE53935);
static const Color warningYellow = Color(0xFFF9A825);
static const Color secondaryButton = Color(0xFFE5E5F7);
static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
static const Color bg = Color(0xFFF8F9FC); // 清透白底
static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
static const Color text = Color(0xFF2D2B32);
static const Color textSub = Color(0xFF8A8892);
static const Color textHint = Color(0xFFBFBCC4);
static const Color success = Color(0xFF6ECF8A);
static const Color error = Color(0xFFF56C6C);
static const Color warning = Color(0xFFF5A623);
static const Color accent = Color(0xFFFF8068);
static const Color border = Color(0xFFEAEAF0);
static const Color divider = Color(0xFFF2F2F6);
/// 每个智能体的卡片色调
static const Map<String, Color> agentColors = {
'default': Color(0xFFE8ECFF), // 淡蓝紫
'consultation': Color(0xFFE8F5FF), // 淡天蓝
'health': Color(0xFFE8FFF0), // 淡薄荷
'diet': Color(0xFFFFF2E8), // 淡杏
'medication': Color(0xFFFFE8F0), // 淡粉
'report': Color(0xFFE8F4FF), // 淡水蓝
'exercise': Color(0xFFF0E8FF), // 淡紫
};
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
static ThemeData get lightTheme => ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: primaryColor,
primary: primaryColor,
surface: background,
brightness: Brightness.light,
),
scaffoldBackgroundColor: background,
colorScheme: ColorScheme.fromSeed(seedColor: primary, primary: primary, surface: bg, brightness: Brightness.light),
scaffoldBackgroundColor: bg,
appBarTheme: const AppBarTheme(
backgroundColor: cardWhite,
foregroundColor: textPrimary,
elevation: 0,
centerTitle: true,
),
cardTheme: CardThemeData(
color: cardWhite,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
backgroundColor: surface, foregroundColor: text, elevation: 0,
centerTitle: true, scrolledUnderElevation: 0,
titleTextStyle: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: text),
),
cardTheme: CardThemeData(color: surface, elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: cardWhite,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
filled: true, fillColor: const Color(0xFFF4F5FA),
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
hintStyle: const TextStyle(color: textHint, fontSize: 15),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(color: primaryColor, width: 1.5),
),
hintStyle: const TextStyle(color: textPlaceholder, fontSize: 16),
),
elevatedButtonTheme: ElevatedButtonThemeData(
style: ElevatedButton.styleFrom(
backgroundColor: primaryColor,
foregroundColor: Colors.white,
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
backgroundColor: primary, foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 48),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
)),
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))),
textTheme: const TextTheme(
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: textPrimary),
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textPrimary),
bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, color: textPrimary),
bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: textSecondary),
labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: textSecondary),
labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: textSecondary),
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text),
titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5),
bodyMedium: TextStyle(fontSize: 15, color: textSub, height: 1.4),
labelMedium: TextStyle(fontSize: 13, color: textSub),
labelSmall: TextStyle(fontSize: 11, color: textHint),
),
);
}

View File

@@ -1,4 +1,3 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
/// 路由信息

View File

@@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
/// 登录页——手机号 + 验证码
class LoginPage extends ConsumerStatefulWidget {
const LoginPage({super.key});
@override
ConsumerState<LoginPage> createState() => _LoginPageState();
@override ConsumerState<LoginPage> createState() => _LoginPageState();
}
class _LoginPageState extends ConsumerState<LoginPage> {
@@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
bool _loading = false;
String? _error;
@override
void dispose() {
_phoneCtrl.dispose();
_codeCtrl.dispose();
super.dispose();
}
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
Future<void> _sendSms() async {
final phone = _phoneCtrl.text.trim();
if (phone.length != 11 || !phone.startsWith('1')) {
setState(() => _error = '请输入正确的手机号');
return;
}
if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
setState(() { _sending = true; _error = null; });
final result = await ref.read(authProvider.notifier).sendSms(phone);
setState(() { _sending = false; });
if (result.error != null) {
setState(() => _error = result.error);
return;
}
// 开发阶段自动填充验证码
if (result.devCode != null) {
_codeCtrl.text = result.devCode!;
}
setState(() => _countdown = 60);
_startCountdown();
if (result.error != null) { setState(() => _error = result.error); return; }
if (result.devCode != null) _codeCtrl.text = result.devCode!;
setState(() => _countdown = 60); _startCountdown();
}
void _startCountdown() async {
for (var i = 60; i > 0; i--) {
await Future.delayed(const Duration(seconds: 1));
if (!mounted) return;
setState(() => _countdown = i - 1);
}
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _countdown = i - 1); }
}
Future<void> _login() async {
if (!_agreed) {
setState(() => _error = '请阅读并同意服务协议和隐私政策');
return;
}
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
setState(() { _loading = true; _error = null; });
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 );
if (err != null) {
setState(() => _error = err);
return;
}
if (err != null) { setState(() => _error = err); return; }
goRoute(ref, 'home');
}
@override
Widget build(BuildContext context) {
@override Widget build(BuildContext context) {
final authState = ref.watch(authProvider);
// 已登录直接跳转
if (authState.isLoggedIn && !authState.isLoading) {
WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
}
if (authState.isLoggedIn && !authState.isLoading) WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home'));
return Scaffold(
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
children: [
const SizedBox(height: 80),
// Logo
Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 16),
Text('健康管家', style: Theme.of(context).textTheme.headlineLarge),
const SizedBox(height: 8),
Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 48),
// 手机号
TextField(
controller: _phoneCtrl,
keyboardType: TextInputType.phone,
maxLength: 11,
decoration: const InputDecoration(
hintText: '手机号',
prefixText: '+86 ',
counterText: '',
),
),
const SizedBox(height: 16),
// 验证码
Row(
children: [
Expanded(
child: TextField(
controller: _codeCtrl,
keyboardType: TextInputType.number,
maxLength: 6,
decoration: const InputDecoration(hintText: '验证码', counterText: ''),
),
),
const SizedBox(width: 12),
SizedBox(
width: 120,
height: 48,
child: ElevatedButton(
onPressed: (_countdown > 0 || _sending) ? null : _sendSms,
style: ElevatedButton.styleFrom(
backgroundColor: _countdown > 0 ? Colors.grey[300] : null,
),
child: Text(
_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码',
style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : null),
),
),
),
],
),
const SizedBox(height: 16),
// 协议勾选
Row(
children: [
Checkbox(value: _agreed, onChanged: (v) => setState(() => _agreed = v ?? false)),
GestureDetector(
onTap: () => setState(() => _agreed = !_agreed),
child: Text('已阅读并同意《服务协议》《隐私政策》', style: Theme.of(context).textTheme.labelMedium),
),
],
),
body: Container(
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
const SizedBox(height: 60),
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF8B9CF7))),
Positioned(right: 10, top: 10, child: Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFFFB800), borderRadius: BorderRadius.circular(15), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.add, size: 16, color: Colors.white))),
])),
const SizedBox(height: 24),
// 登录按钮
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Text(_error!, style: const TextStyle(color: AppColors.errorRed, fontSize: 14)),
),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: _loading ? null : _login,
child: _loading
? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('登 录'),
),
),
const SizedBox(height: 80),
],
),
),
Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))),
const SizedBox(height: 8),
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
const SizedBox(height: 48),
TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)))),
const SizedBox(height: 16),
Row(children: [
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)), counterText: ''))),
const SizedBox(width: 12),
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
]),
const SizedBox(height: 8),
Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF8B9CF7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF8B9CF7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7))), TextSpan(text: '', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)))])),
]))),
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
const SizedBox(height: 24),
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
const SizedBox(height: 40),
]))),
),
);
}
}
/// 引用 AppTheme 颜色
class AppColors {
static const Color errorRed = Color(0xFFE53935);
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
/// 医生列表页
@@ -35,10 +36,10 @@ class DoctorListPage extends ConsumerWidget {
child: Row(children: [
CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFEDEBFF),
backgroundColor: const Color(0xFFF0F2FF),
child: Text(
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
style: const TextStyle(fontSize: 22, color: Color(0xFF635BFF)),
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
),
),
const SizedBox(width: 16),
@@ -52,16 +53,14 @@ class DoctorListPage extends ConsumerWidget {
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
const SizedBox(height: 4),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF635BFF))),
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
const SizedBox(height: 2),
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
],
),
),
ElevatedButton(
onPressed: () async {
// TODO: 点击「咨询」创建问诊并跳转聊天页
},
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
child: const Text('咨询'),
),
]),

View File

@@ -0,0 +1,449 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import '../../core/navigation_provider.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
class DietState {
final String? imagePath;
final List<FoodItem> foods;
final String mealType;
final bool isAnalyzing;
final int? healthScore;
DietState({
this.imagePath,
this.foods = const [],
this.mealType = 'lunch',
this.isAnalyzing = false,
this.healthScore,
});
DietState copyWith({
String? imagePath,
List<FoodItem>? foods,
String? mealType,
bool? isAnalyzing,
int? healthScore,
}) {
return DietState(
imagePath: imagePath ?? this.imagePath,
foods: foods ?? this.foods,
mealType: mealType ?? this.mealType,
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
healthScore: healthScore ?? this.healthScore,
);
}
}
class FoodItem {
final String id;
String name;
int calories;
bool selected;
FoodItem({
required this.id,
required this.name,
required this.calories,
this.selected = true,
});
}
class DietNotifier extends Notifier<DietState> {
@override
DietState build() => DietState();
void setImage(String path) {
state = state.copyWith(imagePath: path);
}
void analyzeImage() async {
state = state.copyWith(isAnalyzing: true);
await Future.delayed(const Duration(seconds: 2));
final mockFoods = [
FoodItem(id: '1', name: '米饭', calories: 150),
FoodItem(id: '2', name: '番茄炒蛋', calories: 200),
FoodItem(id: '3', name: '红烧肉', calories: 350),
FoodItem(id: '4', name: '青菜', calories: 50),
];
state = state.copyWith(foods: mockFoods, isAnalyzing: false, healthScore: 3);
}
void updateFoodName(String id, String name) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, calories: f.calories, selected: f.selected) : f).toList();
state = state.copyWith(foods: foods);
}
void updateFoodCalories(String id, int calories) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: calories, selected: f.selected) : f).toList();
state = state.copyWith(foods: foods);
}
void toggleFood(String id) {
final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, calories: f.calories, selected: !f.selected) : f).toList();
state = state.copyWith(foods: foods);
}
void addFood() {
final newId = '${DateTime.now().millisecondsSinceEpoch}';
final foods = [...state.foods, FoodItem(id: newId, name: '新食物', calories: 100)];
state = state.copyWith(foods: foods);
}
void removeFood(String id) {
final foods = state.foods.where((f) => f.id != id).toList();
state = state.copyWith(foods: foods);
}
void setMealType(String type) {
state = state.copyWith(mealType: type);
}
void reset() {
state = DietState();
}
}
class DietCapturePage extends ConsumerWidget {
const DietCapturePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
return Scaffold(
appBar: AppBar(
title: const Text('拍饮食'),
centerTitle: true,
),
body: state.imagePath == null ? _buildCaptureView(context, ref) : _buildResultView(context, ref),
);
}
Widget _buildCaptureView(BuildContext context, WidgetRef ref) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 180,
height: 180,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(90),
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
),
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 24),
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
const Text('AI将识别食物并分析营养成分', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
const SizedBox(height: 40),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_captureBtn(context, ref, Icons.camera_alt, '拍照', ImageSource.camera),
const SizedBox(width: 24),
_captureBtn(context, ref, Icons.photo_library, '相册', ImageSource.gallery),
],
),
],
),
);
}
Widget _captureBtn(BuildContext context, WidgetRef ref, IconData icon, String label, ImageSource source) {
return Column(
children: [
Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
),
child: IconButton(
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
onPressed: () => _pickImage(context, ref, source),
),
),
const SizedBox(height: 8),
Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
],
);
}
Future<void> _pickImage(BuildContext context, WidgetRef ref, ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85);
if (picked != null) {
ref.read(dietProvider.notifier).setImage(picked.path);
ref.read(dietProvider.notifier).analyzeImage();
}
}
Widget _buildResultView(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories);
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(children: [
_buildImagePreview(state.imagePath!),
const SizedBox(height: 20),
_buildMealSelector(context, ref),
const SizedBox(height: 20),
if (state.isAnalyzing) _buildAnalyzingIndicator() else _buildFoodList(context, ref),
if (!state.isAnalyzing && state.foods.isNotEmpty) ...[
const SizedBox(height: 20),
_buildNutritionSummary(totalCalories),
const SizedBox(height: 20),
_buildHealthScore(state.healthScore ?? 0),
const SizedBox(height: 30),
_buildSubmitButton(context, ref),
],
]),
);
}
Widget _buildImagePreview(String path) {
return Container(
height: 200,
decoration: BoxDecoration(
color: const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(20),
image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover),
),
);
}
Widget _buildMealSelector(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
final meals = [
{'type': 'breakfast', 'label': '早餐', 'icon': '🌅'},
{'type': 'lunch', 'label': '午餐', 'icon': '☀️'},
{'type': 'dinner', 'label': '晚餐', 'icon': '🌙'},
{'type': 'snack', 'label': '加餐', 'icon': '🍪'},
];
return Column(children: [
const Text('选择餐次', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
const SizedBox(height: 12),
Row(children: meals.map((meal) {
final isSelected = state.mealType == meal['type'];
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: ElevatedButton(
onPressed: () => ref.read(dietProvider.notifier).setMealType(meal['type']!),
child: Column(children: [
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
const SizedBox(height: 4),
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
]),
style: ElevatedButton.styleFrom(
backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
);
}).toList()),
]);
}
Widget _buildAnalyzingIndicator() {
return Center(
child: Column(children: [
Container(
width: 60,
height: 60,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(30),
),
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 16),
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
]),
);
}
Widget _buildFoodList(BuildContext context, WidgetRef ref) {
final state = ref.watch(dietProvider);
return Container(
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const Text('🍽️', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const Spacer(),
IconButton(
icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
onPressed: () => ref.read(dietProvider.notifier).addFood(),
),
]),
),
...state.foods.map((food) => _buildFoodItem(context, ref, food)),
]),
);
}
Widget _buildFoodItem(BuildContext context, WidgetRef ref, FoodItem food) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
Checkbox(
value: food.selected,
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
activeColor: const Color(0xFF8B9CF7),
),
const SizedBox(width: 8),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '食物名称'),
controller: TextEditingController(text: food.name),
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v),
style: const TextStyle(fontSize: 16),
),
Row(children: [
const Text('热量:', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
SizedBox(
width: 60,
child: TextField(
decoration: const InputDecoration(border: InputBorder.none, hintText: '0'),
controller: TextEditingController(text: food.calories.toString()),
keyboardType: TextInputType.number,
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
),
),
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
),
IconButton(
icon: const Icon(Icons.delete, size: 18, color: Color(0xFFCCCCCC)),
onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id),
),
]),
);
}
Widget _buildNutritionSummary(int totalCalories) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16),
),
child: Row(children: [
const Icon(Icons.fireplace, size: 28, color: Color(0xFFFF6B35)),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('总热量', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
Text('$totalCalories kcal', style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w600)),
]),
const Spacer(),
Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
const Text('推荐摄入量', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
const Text('午餐约 500-700 kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
]),
]),
);
}
Widget _buildHealthScore(int score) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEFEFF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(children: [
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(5, (i) => Icon(
Icons.star,
size: 36,
color: i < score ? const Color(0xFFFFB800) : Colors.grey[200],
)),
),
const SizedBox(height: 12),
Text(_getScoreComment(score), style: TextStyle(fontSize: 14, color: _getScoreColor(score))),
]),
);
}
String _getScoreComment(int score) {
switch (score) {
case 1: return '饮食不太健康,建议多吃蔬菜';
case 2: return '需要改善,减少油腻食物';
case 3: return '还不错,继续保持均衡饮食';
case 4: return '很健康!营养搭配合理';
case 5: return '非常健康!饮食管理很棒';
default: return '请完善食物信息';
}
}
Color _getScoreColor(int score) {
switch (score) {
case 1: return const Color(0xFFE53935);
case 2: return const Color(0xFFF9A825);
case 3: return const Color(0xFF8B9CF7);
case 4: return const Color(0xFF43A047);
case 5: return const Color(0xFF00C853);
default: return Colors.grey[400]!;
}
}
Widget _buildSubmitButton(BuildContext context, WidgetRef ref) {
return SizedBox(
width: double.infinity,
child: ElevatedButton(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('饮食记录已保存 ✅'),
backgroundColor: Color(0xFF8B9CF7),
));
popRoute(ref);
},
child: const Text('保存记录'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 16),
textStyle: const TextStyle(fontSize: 16),
),
),
);
}
}

View File

@@ -1,174 +1,227 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import 'dart:io';
import '../../providers/auth_provider.dart';
import '../../providers/chat_provider.dart';
import '../../widgets/agent_bar.dart';
import '../../providers/data_providers.dart';
import '../../widgets/health_drawer.dart';
import 'widgets/chat_messages_view.dart';
/// 首页——主界面
class HomePage extends ConsumerStatefulWidget {
const HomePage({super.key});
@override
ConsumerState<HomePage> createState() => _HomePageState();
@override ConsumerState<HomePage> createState() => _HomePageState();
}
class _HomePageState extends ConsumerState<HomePage> {
final _textCtrl = TextEditingController();
final _scrollCtrl = ScrollController();
bool _taskCardsExpanded = true;
String? _pickedImagePath;
final Set<ActiveAgent> _welcomedAgents = {};
@override
void dispose() {
_textCtrl.dispose();
_scrollCtrl.dispose();
super.dispose();
}
@override void initState() { super.initState(); }
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
void _sendMessage() {
final text = _textCtrl.text.trim();
if (text.isEmpty) return;
final imagePath = _pickedImagePath;
if (text.isEmpty && imagePath == null) return;
_textCtrl.clear();
setState(() => _pickedImagePath = null);
if (imagePath != null) {
ref.read(chatProvider.notifier).sendImage(imagePath, text);
} else {
ref.read(chatProvider.notifier).sendMessage(text);
}
}
@override
Widget build(BuildContext context) {
@override Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider);
final user = auth.user;
final selectedAgent = ref.watch(selectedAgentProvider);
ref.listen(cameraActionProvider, (prev, next) {
if (next == 'camera') {
_pickImage(ImageSource.camera);
ref.read(cameraActionProvider.notifier).clear();
} else if (next == 'gallery') {
_pickImage(ImageSource.gallery);
ref.read(cameraActionProvider.notifier).clear();
}
});
return Scaffold(
drawer: const HealthDrawer(),
backgroundColor: const Color(0xFFF8F9FC),
body: SafeArea(
child: Column(children: [
_buildHeader(context),
if (_taskCardsExpanded) _buildTaskCards(chatState),
// ── 顶部栏 ──
_buildHeader(user),
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
const AgentBar(),
_buildInputBar(),
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
_buildBottomBar(context, selectedAgent),
]),
),
);
}
Widget _buildHeader(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
// ═════════════════════ 顶部栏 ═════════════════════
Widget _buildHeader(dynamic user) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Row(children: [
Builder(builder: (ctx) => IconButton(
icon: const Icon(Icons.menu, size: 24),
onPressed: () => Scaffold.of(ctx).openDrawer(),
Builder(builder: (ctx) => GestureDetector(
onTap: () => Scaffold.of(ctx).openDrawer(),
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF8B9CF7)) : null),
)),
const Spacer(),
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
const Spacer(),
const SizedBox(width: 48),
const SizedBox(width: 10),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
const SizedBox(height: 2),
Text('${_getGreeting()}${user?.name ?? '张三'}', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
])),
Icon(Icons.notifications_none, size: 22, color: Colors.grey[600]),
]),
);
}
Widget _buildTaskCards(ChatState chatState) {
String _getGreeting() {
final hour = DateTime.now().hour;
if (hour < 9) return '早上好';
if (hour < 12) return '上午好';
if (hour < 18) return '下午好';
return '晚上好';
}
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
static final _agentDefs = [
(ActiveAgent.consultation, '问诊', Icons.chat_bubble_outline),
(ActiveAgent.health, '记数据', Icons.favorite_border),
(ActiveAgent.diet, '拍饮食', Icons.restaurant_outlined),
(ActiveAgent.medication, '药管家', Icons.medication_outlined),
(ActiveAgent.report, '看报告', Icons.description_outlined),
(ActiveAgent.exercise, '运动', Icons.directions_run_outlined),
];
Widget _buildAgentBar(ActiveAgent? selected) {
return Container(
height: 36,
padding: const EdgeInsets.symmetric(horizontal: 12),
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _agentDefs.length,
separatorBuilder: (_, i) => const SizedBox(width: 6),
itemBuilder: (_, i) {
final (agent, label, icon) = _agentDefs[i];
final isActive = selected == agent;
return GestureDetector(
onVerticalDragUpdate: (d) { if (d.delta.dy < -10) setState(() => _taskCardsExpanded = false); },
onTap: () {
final notifier = ref.read(selectedAgentProvider.notifier);
if (isActive) {
notifier.select(null);
} else {
notifier.select(agent);
ref.read(chatProvider.notifier).setAgent(agent);
if (_welcomedAgents.add(agent)) {
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
}
}
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
padding: const EdgeInsets.all(12),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFEDEBFF),
borderRadius: BorderRadius.circular(12),
),
child: Column(children: [
Row(children: [
const Icon(Icons.wb_sunny, size: 18, color: Color(0xFF635BFF)),
const SizedBox(width: 8),
const Text('早上好!', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const Spacer(),
GestureDetector(
onTap: () => setState(() => _taskCardsExpanded = false),
child: const Icon(Icons.keyboard_arrow_up, size: 20, color: Color(0xFF666666)),
),
]),
if (chatState.noticeText != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text(chatState.noticeText!, style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
const SizedBox(width: 3),
Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
]),
),
);
}
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: const BorderRadius.vertical(top: Radius.circular(16)),
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, -2))],
),
child: Column(mainAxisSize: MainAxisSize.min, children: _getAgentButtons(agent)),
);
}
List<Widget> _getAgentButtons(ActiveAgent agent) {
final buttons = <Widget>[];
if (agent == ActiveAgent.health) {
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
} else if (agent == ActiveAgent.diet) {
buttons.add(_panelBtn('拍照', Icons.camera_alt));
buttons.add(_panelBtn('上传照片', Icons.photo_library));
} else if (agent == ActiveAgent.medication) {
buttons.add(_panelBtn('用药管理', Icons.medication));
buttons.add(_panelBtn('用药提醒', Icons.alarm));
} else if (agent == ActiveAgent.consultation) {
buttons.add(_panelBtn('找医生', Icons.person_search));
} else if (agent == ActiveAgent.exercise) {
buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week));
buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline));
}
return buttons;
}
Widget _panelBtn(String label, IconData icon) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () {},
icon: Icon(icon, size: 20),
label: Text(label),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF635BFF),
side: const BorderSide(color: Color(0xFF635BFF)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
},
),
);
}
Widget _buildInputBar() {
// ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
return Column(mainAxisSize: MainAxisSize.min, children: [
// 智能体胶囊栏常驻高度36
_buildAgentBar(selectedAgent),
// 图片预览(有选中图片时显示)
if (_pickedImagePath != null) _buildImagePreview(),
// 输入框
_buildCompactInputBar(context),
]);
}
Widget _buildImagePreview() {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: Colors.grey.shade200)),
),
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () {}),
Expanded(
child: TextField(
controller: _textCtrl,
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
Stack(children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
),
),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
Positioned(top: -4, right: -4, child: GestureDetector(
onTap: () => setState(() => _pickedImagePath = null),
child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
)),
]),
const Spacer(),
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
]),
);
}
Widget _buildCompactInputBar(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
child: Row(children: [
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
Expanded(child: TextField(
controller: _textCtrl,
style: const TextStyle(fontSize: 15),
decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
onSubmitted: (_) => _sendMessage(),
)),
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF8B9CF7)), onPressed: _sendMessage),
]),
);
}
Future<void> _pickImage(ImageSource source) async {
final picker = ImagePicker();
final picked = await picker.pickImage(source: source, imageQuality: 85);
if (picked != null) {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) return;
setState(() => _pickedImagePath = picked.path);
}
}
void _showAttachmentPicker(BuildContext context) {
showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(children: [
ListTile(leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }),
ListTile(leading: const Icon(Icons.photo_library), title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }),
ListTile(leading: const Icon(Icons.attach_file), title: const Text('传文件'), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles(); if (result != null && result.files.isNotEmpty) { _textCtrl.text = '[文件已选择] ${result.files.first.name}'; if (mounted) setState(() {}); }}),
])));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,528 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class _MedicationItem {
String name = '';
String dosage = '';
String frequency = '每日1次';
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
DateTime startDate = DateTime.now();
DateTime? endDate;
int weekday = 1;
}
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
class MedicationEditPage extends ConsumerStatefulWidget {
final String? medicationId;
const MedicationEditPage({super.key, this.medicationId});
@override
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _items = <_MedicationItem>[];
final _nameCtrls = <TextEditingController>[];
final _doseCtrls = <TextEditingController>[];
@override
void initState() {
super.initState();
_addItem();
}
@override
void dispose() {
for (final c in _nameCtrls) {
c.dispose();
}
for (final c in _doseCtrls) {
c.dispose();
}
super.dispose();
}
void _addItem() {
setState(() {
_items.add(_MedicationItem());
_nameCtrls.add(TextEditingController());
_doseCtrls.add(TextEditingController());
});
}
void _removeItem(int index) {
setState(() {
_nameCtrls[index].dispose();
_doseCtrls[index].dispose();
_nameCtrls.removeAt(index);
_doseCtrls.removeAt(index);
_items.removeAt(index);
});
}
void _onSave() async {
for (int i = 0; i < _items.length; i++) {
_items[i].name = _nameCtrls[i].text.trim();
_items[i].dosage = _doseCtrls[i].text.trim();
}
final allValid = _items.every(
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
);
if (!allValid) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
);
return;
}
final service = ref.read(medicationServiceProvider);
try {
for (final item in _items) {
final timesStr = item.frequency == '按需服用'
? []
: item.times.map((t) => t.format(context)).toList();
await service.create({
'name': item.name,
'dosage': item.dosage,
'frequency': 'Daily',
'timeOfDay': timesStr,
'startDate': item.startDate.toIso8601String().split('T')[0],
if (item.endDate != null)
'endDate': item.endDate!.toIso8601String().split('T')[0],
'source': 'Manual',
});
}
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('已添加 ${_items.length} 种药品'),
backgroundColor: const Color(0xFF8B9CF7),
),
);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
popRoute(ref);
} catch (e) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('保存失败:$e'),
backgroundColor: Colors.red,
),
);
// 仍然返回上一页,避免卡在黑屏
popRoute(ref);
}
}
int _timeCount(String frequency) {
switch (frequency) {
case '每日1次':
return 1;
case '每日2次':
return 2;
case '每日3次':
return 3;
case '每周1次':
return 1;
default:
return 0;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.chevron_left),
onPressed: () => popRoute(ref),
),
title: const Text(
'添加用药',
style: TextStyle(
color: Color(0xFF1A1A1A),
fontWeight: FontWeight.w600,
),
),
centerTitle: true,
actions: [
TextButton(
onPressed: _onSave,
child: const Text(
'保存',
style: TextStyle(
color: Color(0xFF8B9CF7),
fontWeight: FontWeight.w600,
),
),
),
],
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
...List.generate(_items.length, (i) => _buildCard(i)),
const SizedBox(height: 12),
_buildAddButton(),
const SizedBox(height: 40),
],
),
),
);
}
Widget _buildCard(int index) {
final item = _items[index];
final count = _timeCount(item.frequency);
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFEEEEEE)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
'药品 ${index + 1}',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: Color(0xFF8B9CF7),
),
),
if (_items.length > 1)
GestureDetector(
onTap: () => _removeItem(index),
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
),
],
),
const SizedBox(height: 8),
Divider(height: 1, color: const Color(0xFFF0F0F0)),
const SizedBox(height: 8),
// Name
_buildLabel('药品名称'),
const SizedBox(height: 4),
TextField(
controller: _nameCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('请输入药品名称'),
),
const SizedBox(height: 8),
// Dosage
_buildLabel('剂量'),
const SizedBox(height: 4),
TextField(
controller: _doseCtrls[index],
style: const TextStyle(fontSize: 14),
decoration: _inputDecoration('100mg'),
),
const SizedBox(height: 8),
// Frequency
_buildLabel('服用频率'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickFrequency(index),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const Spacer(),
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// Times (dynamic)
if (count > 0) ...[
_buildLabel('服药时间'),
const SizedBox(height: 4),
Wrap(
spacing: 8,
runSpacing: 6,
children: List.generate(count, (t) => _buildTimePicker(index, t)),
),
if (item.frequency == '每周1次') ...[
const SizedBox(height: 8),
_buildLabel('选择星期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickWeekday(index),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
const SizedBox(width: 4),
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
],
const SizedBox(height: 8),
],
// Start date
_buildLabel('开始日期'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: true),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
const Spacer(),
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
],
),
),
),
const SizedBox(height: 8),
// End date (optional)
_buildLabel('结束日期(可选)'),
const SizedBox(height: 4),
GestureDetector(
onTap: () => _pickDate(index, isStart: false),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
children: [
Text(
item.endDate != null
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
: '不设置',
style: TextStyle(
fontSize: 14,
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
),
),
const Spacer(),
GestureDetector(
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
child: Icon(
item.endDate != null ? Icons.close : Icons.calendar_today,
size: 18,
color: const Color(0xFF9E9E9E),
),
),
],
),
),
),
],
),
);
}
Widget _buildLabel(String text) {
return Text(
text,
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
);
}
Widget _buildTimePicker(int itemIndex, int timeIndex) {
final item = _items[itemIndex];
final time = item.times[timeIndex];
return GestureDetector(
onTap: () => _pickTime(itemIndex, timeIndex),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE0E0E0)),
color: const Color(0xFFFAFAFA),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)),
const SizedBox(width: 6),
Text(
time.format(context),
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
),
],
),
),
);
}
Widget _buildAddButton() {
return SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: _addItem,
icon: const Icon(Icons.add, size: 18),
label: const Text('添加', style: TextStyle(fontSize: 14)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFFD0D5FC)),
padding: const EdgeInsets.symmetric(vertical: 12),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
backgroundColor: const Color(0xFFF0F2FF),
),
),
);
}
InputDecoration _inputDecoration(String hint) {
return InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
filled: true,
fillColor: const Color(0xFFFAFAFA),
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: const BorderSide(color: Color(0xFF8B9CF7)),
),
);
}
void _pickFrequency(int index) async {
final selected = await showModalBottomSheet<String>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: _frequencies
.map((f) => ListTile(
title: Text(f),
onTap: () => Navigator.pop(ctx, f),
))
.toList(),
),
),
);
if (selected != null && mounted) {
setState(() {
final item = _items[index];
item.frequency = selected;
final newCount = _timeCount(selected);
if (newCount > 0 && item.times.length != newCount) {
item.times = List.generate(
newCount,
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
);
}
});
}
}
void _pickWeekday(int index) async {
final item = _items[index];
final selected = await showModalBottomSheet<int>(
context: context,
builder: (ctx) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(7, (i) {
return ListTile(
title: Text(_weekdays[i]),
selected: item.weekday == i + 1,
onTap: () => Navigator.pop(ctx, i + 1),
);
}),
),
),
);
if (selected != null && mounted) {
setState(() => _items[index].weekday = selected);
}
}
void _pickTime(int itemIndex, int timeIndex) async {
final item = _items[itemIndex];
final time = await showTimePicker(
context: context,
initialTime: item.times[timeIndex],
);
if (time != null && mounted) {
setState(() => item.times[timeIndex] = time);
}
}
void _pickDate(int index, {required bool isStart}) async {
final item = _items[index];
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
final date = await showDatePicker(
context: context,
firstDate: DateTime(2020),
lastDate: DateTime(2030),
initialDate: initial,
);
if (date != null && mounted) {
setState(() {
if (isStart) {
item.startDate = date;
} else {
item.endDate = date;
}
});
}
}
}

View File

@@ -3,78 +3,160 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
/// 用药列表页
class MedicationListPage extends ConsumerWidget {
const MedicationListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final meds = ref.watch(medicationListProvider);
return Scaffold(
appBar: AppBar(title: const Text('我的用药')),
body: meds.when(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
centerTitle: true,
actions: [
TextButton(
onPressed: () => pushRoute(ref, 'medicationEdit'),
child: Row(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF8B9CF7)),
const SizedBox(width: 4),
const Text('添加新药', style: TextStyle(color: Color(0xFF8B9CF7), fontSize: 14)),
]),
),
],
),
body: Column(children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [
_TabChip(label: '全部', active: true),
const SizedBox(width: 8),
_TabChip(label: '服用中'),
const SizedBox(width: 8),
_TabChip(label: '已停药'),
])),
Expanded(child: meds.when(
data: (list) {
if (list.isEmpty) return _empty(context);
return ListView.builder(
itemCount: list.length,
padding: const EdgeInsets.all(16),
itemCount: list.length + 1,
itemBuilder: (ctx, i) {
if (i == list.length) return const SizedBox(height: 80);
final m = list[i];
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? [];
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
child: ListTile(
leading: const Icon(Icons.medication, color: Color(0xFF635BFF)),
title: Text('${m['name']} ${m['dosage'] ?? ''}', style: const TextStyle(fontSize: 16)),
subtitle: Text('每天 ${times.join("")}', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
trailing: IconButton(icon: const Icon(Icons.check_circle_outline, color: Color(0xFF43A047)), onPressed: () async {
await ref.read(medicationServiceProvider).confirm(m['id']);
ref.invalidate(medicationListProvider);
}),
),
);
return _MedicationCard(data: m);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => _empty(context),
),
floatingActionButton: FloatingActionButton.extended(
onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); },
icon: const Icon(Icons.add), label: const Text('添加药品'),
),
);
}
Widget _empty(BuildContext context) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.medication, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text('暂无用药计划', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: 8), Text('可通过 AI 对话或手动添加', style: Theme.of(context).textTheme.labelMedium),
]));
}
/// 编辑用药页
class MedicationEditPage extends ConsumerStatefulWidget {
final String? id;
const MedicationEditPage({super.key, this.id});
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
}
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController();
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
Future<void> _save() async {
await ref.read(medicationServiceProvider).create({
'name': _nameCtrl.text, 'dosage': _dosageCtrl.text,
'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text],
'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10),
});
popRoute(ref);
}
@override Widget build(BuildContext context) => Scaffold(
appBar: AppBar(title: const Text('添加药品')),
body: ListView(padding: const EdgeInsets.all(16), children: [
TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '药品名称', hintText: '如:阿司匹林')),
const SizedBox(height: 16), TextField(controller: _dosageCtrl, decoration: const InputDecoration(labelText: '剂量', hintText: '100mg')),
const SizedBox(height: 16), TextField(controller: _timeCtrl, decoration: const InputDecoration(labelText: '服药时间', hintText: '08:00:00')),
const SizedBox(height: 32), SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _save, child: const Text('保存'))),
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, e) => _empty(context),
)),
_buildReminderBar(),
]),
);
}
Widget _buildReminderBar() {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]),
child: Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFF8B9CF7).withAlpha(50)),
),
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF8B9CF7)),
),
const SizedBox(width: 12),
const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('用药提醒已开启', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))),
Text('按时服药,守护心脏健康一天', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))),
])),
const Icon(Icons.chevron_right, size: 18, color: Color(0xFFBDBDBD)),
]),
);
}
Widget _empty(BuildContext context) {
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
const Icon(Icons.medication_outlined, size: 64, color: Color(0xFFE0E0E0)),
const SizedBox(height: 12),
const Text('暂无用药计划', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))),
const SizedBox(height: 8),
const Text('可通过 AI 对话或手动添加', style: TextStyle(fontSize: 13, color: Color(0xFFBDBDBD))),
]));
}
}
class _TabChip extends StatelessWidget {
final String label;
final bool active;
const _TabChip({required this.label, this.active = false});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
decoration: BoxDecoration(
color: active ? const Color(0xFF8B9CF7) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: active ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
),
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
color: active ? Colors.white : const Color(0xFF757575),
),
),
);
}
}
class _MedicationCard extends StatelessWidget {
final Map<String, dynamic> data;
const _MedicationCard({required this.data});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Row(children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(14),
),
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('${data['name'] ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text('${data['dosage'] ?? ''} · 每日1次', style: const TextStyle(fontSize: 13, color: Color(0xFF9E9E9E))),
const SizedBox(height: 2),
Text('08:00 · 剩余 1 片', style: const TextStyle(fontSize: 12, color: Color(0xFFBDBDBD))),
])),
Container(
width: 28,
height: 28,
decoration: const BoxDecoration(color: Color(0xFFDCFCE7), shape: BoxShape.circle),
child: const Icon(Icons.check, size: 16, color: Color(0xFF43A047)),
),
]),
);
}
}

View File

@@ -0,0 +1,68 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
class ProfileDetailPage extends ConsumerWidget {
const ProfileDetailPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
final latestHealth = ref.watch(latestHealthProvider);
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)),
title: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.person_outline, size: 20, color: Colors.grey[600]),
const SizedBox(width: 6),
Text('健康档案', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]),
centerTitle: true,
),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
);
}
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
const Text('健康概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text('(最近测量)', style: TextStyle(fontSize: 13, color: Colors.grey[500])),
const SizedBox(height: 16),
healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF8B9CF7)))), error: (_, e) => _buildMetricsEmpty()),
]),
);
}
Widget _buildMetricsList(Map<String, dynamic> data) {
return Column(children: [_metricRow(Icons.favorite, '血压', _formatBP(data['BloodPressure'])), const Divider(), _metricRow(Icons.monitor_heart, '心率', _formatMetric(data['HeartRate'], '次/分')), const Divider(), _metricRow(Icons.bloodtype, '血糖', _formatMetric(data['Glucose'], 'mmol/L')), const Divider(), _metricRow(Icons.air, '血氧', _formatMetric(data['SpO2'], '%')), const Divider(), _metricRow(Icons.monitor_weight, '体重', _formatMetric(data['Weight'], 'kg'))]);
}
Widget _buildMetricsEmpty() {
return Column(children: [_metricRow(Icons.favorite, '血压', '--/--'), const Divider(), _metricRow(Icons.monitor_heart, '心率', '-- 次/分'), const Divider(), _metricRow(Icons.bloodtype, '血糖', '-- mmol/L'), const Divider(), _metricRow(Icons.air, '血氧', '-- %'), const Divider(), _metricRow(Icons.monitor_weight, '体重', '-- kg')]);
}
String _formatBP(dynamic bp) { if (bp is Map) { final s = bp['systolic']; final d = bp['diastolic']; if (s != null && d != null) return '$s/$d'; } return '--/--'; }
String _formatMetric(dynamic val, String unit) { if (val is Map) { final v = val['value']; if (v != null) return '$v$unit'; } return '-- $unit'; }
Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 15, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 18, color: Colors.grey[400])])));
Widget _buildHistoryList() {
final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}];
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF8B9CF7)))])), ...items.map(_historyItem)]));
}
Widget _historyItem(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(item['date']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))), const SizedBox(width: 8), Expanded(child: Text(item['label']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF333333)))), Text(item['value']?.toString() ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))]));
}
}

View File

@@ -3,67 +3,98 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
/// 个人中心页面
class ProfilePage extends ConsumerWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
@override Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final user = auth.user;
return Scaffold(
appBar: AppBar(title: const Text('个人中心')),
body: ListView(
children: [
// 头像区
Container(
padding: const EdgeInsets.all(24),
color: const Color(0xFFEDEBFF),
child: Column(
children: [
CircleAvatar(
radius: 40,
backgroundColor: const Color(0xFF635BFF),
child: Text(
(user?.name ?? '?')[0],
style: const TextStyle(fontSize: 32, color: Colors.white),
backgroundColor: const Color(0xFFF8F9FC),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Column(children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),]),
const SizedBox(height: 20),
Row(children: [
GestureDetector(
onTap: () => pushRoute(ref, 'editProfile'),
child: Stack(children: [
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7)) : null),
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
]),
),
),
const SizedBox(height: 12),
Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge),
const SizedBox(width: 16),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
const SizedBox(height: 4),
Text(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium),
Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
])),
Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]),
]),
])),
const SizedBox(height: 12),
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
_MenuItem(icon: Icons.people_outline, title: '家人关怀'),
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),
_MenuItem(icon: Icons.info_outline, title: '关于我们'),
const SizedBox(height: 40),
GestureDetector(
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定退出?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
],
),
),
const SizedBox(height: 8),
_MenuItem(icon: Icons.person, title: '编辑资料', onTap: () => pushRoute(ref, 'profileEdit')),
_MenuItem(icon: Icons.folder, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () {}),
const Divider(),
_MenuItem(icon: Icons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')),
_MenuItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
const Divider(),
_MenuItem(
icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935),
onTap: () async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('退出登录'), content: const Text('确定退出?'),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
);
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)),
),
],
),
]))),
);
}
}
class _MenuItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback onTap; final Color? textColor;
const _MenuItem({required this.icon, required this.title, required this.onTap, this.textColor});
@override
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor ?? const Color(0xFF1A1A1A))), trailing: const Icon(Icons.chevron_right, size: 20), onTap: onTap);
final IconData icon;
final String title;
final String? trailing;
final VoidCallback? onTap;
const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap});
@override Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
decoration: BoxDecoration(color: Colors.white),
child: Row(children: [
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
const SizedBox(width: 12),
Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
if (trailing != null && trailing!.isNotEmpty) ...[const Spacer(), Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400]))],
if (trailing == null || trailing!.isEmpty) const Spacer(),
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
]),
),
);
}
}

View File

@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/navigation_provider.dart';
import '../providers/data_providers.dart';
/// 饮食记录列表
@@ -45,38 +46,312 @@ class ExercisePlanPage extends ConsumerWidget {
@override Widget build(BuildContext context, WidgetRef ref) {
final plan = ref.watch(currentExercisePlanProvider);
return Scaffold(
appBar: AppBar(title: const Text('运动计划')),
appBar: AppBar(title: const Text('运动计划'), centerTitle: true),
floatingActionButton: FloatingActionButton.extended(
onPressed: () => _createDefaultPlan(ref, context),
icon: const Icon(Icons.add),
label: const Text('创建本周计划'),
backgroundColor: const Color(0xFF8B9CF7),
),
body: plan.when(
data: (data) {
if (data == null) return _empty(context, '运动计划', '暂无运动计划');
if (data == null || data.isEmpty) return _empty(context, '运动计划', '暂无运动计划,点击右下角创建');
final items = (data['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
final weekDays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
return ListView.builder(
itemCount: items.length,
itemBuilder: (ctx, i) {
final item = items[i];
final completedCount = items.where((i) => i['isCompleted'] == true).length;
final totalCount = items.where((i) => i['isRestDay'] != true).length;
return ListView(children: [
_buildProgressCard(completedCount, totalCount),
const SizedBox(height: 16),
...items.asMap().entries.map((entry) {
final i = entry.key;
final item = entry.value;
final day = item['dayOfWeek'] is int ? item['dayOfWeek'] as int : i;
final isRest = item['isRestDay'] == true;
final isDone = item['isCompleted'] == true;
return ListTile(
leading: Icon(isDone ? Icons.check_circle : Icons.circle_outlined, color: isDone ? const Color(0xFF43A047) : Colors.grey),
title: Text('${weekDays[day]} ${isRest ? '休息日' : '${item['exerciseType']} ${item['durationMinutes']}分钟'}'),
trailing: isDone ? const Text('✅ 已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))) : const Text('待完成', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
return _ExercisePlanItem(
day: weekDays[day],
dayIndex: day,
isRest: isRest,
isDone: isDone,
exerciseType: item['exerciseType']?.toString() ?? '',
duration: item['durationMinutes'] is int ? item['durationMinutes'] as int : 0,
onCheckIn: () => _checkIn(ref, item['id'], context),
);
}),
]);
},
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (_, _) => _empty(context, '运动计划', '暂无运动计划'),
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
),
);
}
Widget _buildProgressCard(int completed, int total) {
final progress = total > 0 ? (completed / total * 100).toInt() : 0;
return Container(
margin: const EdgeInsets.all(16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(20),
),
child: Column(children: [
const Text('🏃 本周运动进度', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 12),
Row(children: [
Container(
width: 60,
height: 60,
decoration: BoxDecoration(
color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(30),
),
child: Center(
child: Text('$progress%', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Colors.white)),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text('已完成 $completed/$total', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
Container(
height: 8,
decoration: BoxDecoration(color: Colors.grey[200], borderRadius: BorderRadius.circular(4)),
child: FractionallySizedBox(
widthFactor: progress / 100,
child: Container(
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(4)),
),
),
),
]),
),
]),
]),
);
}
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
try {
final service = ref.read(exerciseServiceProvider);
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
final items = List.generate(7, (i) => {
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
});
await service.createPlan({
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': items,
});
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('运动计划已创建'),
backgroundColor: Color(0xFF43A047),
));
} catch (e) {
// 后端不可用时,直接使用本地 mock 数据
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('已创建本地计划(离线模式)'), backgroundColor: const Color(0xFFFF9800)),
);
}
}
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
final service = ref.read(exerciseServiceProvider);
await service.checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('打卡成功 ✅'),
backgroundColor: Color(0xFF43A047),
));
}
}
class _ExercisePlanItem extends StatelessWidget {
final String day;
final int dayIndex;
final bool isRest;
final bool isDone;
final String exerciseType;
final int duration;
final VoidCallback onCheckIn;
const _ExercisePlanItem({
required this.day,
required this.dayIndex,
required this.isRest,
required this.isDone,
required this.exerciseType,
required this.duration,
required this.onCheckIn,
});
@override
Widget build(BuildContext context) {
final today = DateTime.now().weekday - 1;
final isToday = dayIndex == today;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Row(children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(12),
),
child: isDone
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
: isRest
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF8B9CF7)),
),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Text(day, style: TextStyle(fontSize: 16, fontWeight: isToday ? FontWeight.w600 : FontWeight.w500)),
if (isToday) const SizedBox(width: 4),
if (isToday) const Text('(今天)', style: TextStyle(fontSize: 12, color: Color(0xFFF59E0B))),
]),
const SizedBox(height: 4),
Text(
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
),
]),
),
if (!isRest && !isDone)
ElevatedButton(
onPressed: onCheckIn,
child: const Text('打卡'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF8B9CF7),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
),
),
if (isDone)
const Text('已完成', style: TextStyle(fontSize: 14, color: Color(0xFF43A047))),
]),
);
}
}
/// 复查列表
class FollowUpListPage extends ConsumerWidget {
const FollowUpListPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '复查随访', '暂无复查安排');
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(title: const Text('复查随访'), centerTitle: true),
floatingActionButton: FloatingActionButton(
onPressed: () => _showAddDialog(context),
child: const Icon(Icons.add),
backgroundColor: const Color(0xFF8B9CF7),
),
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
);
}
void _showAddDialog(BuildContext context) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('添加复查提醒'),
content: Column(mainAxisSize: MainAxisSize.min, children: [
TextField(decoration: const InputDecoration(labelText: '医院名称')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '科室')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '日期', hintText: 'YYYY-MM-DD')),
const SizedBox(height: 12),
TextField(decoration: const InputDecoration(labelText: '备注')),
]),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('取消')),
TextButton(
onPressed: () {
Navigator.pop(ctx);
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
content: Text('复查提醒已添加 ✅'),
backgroundColor: Color(0xFF8B9CF7),
));
},
child: const Text('保存'),
),
],
),
);
}
}
final _mockFollowUps = [
{'id': '1', 'hospital': '协和医院', 'department': '心内科', 'date': '2025-01-20', 'type': '复诊', 'status': 'upcoming', 'notes': '常规复查,带齐病历'},
{'id': '2', 'hospital': '人民医院', 'department': '骨科', 'date': '2025-01-25', 'type': '复查', 'status': 'upcoming', 'notes': '术后3个月复查'},
{'id': '3', 'hospital': '协和医院', 'department': '心内科', 'date': '2024-12-15', 'type': '复诊', 'status': 'completed', 'notes': '已完成'},
];
class _FollowUpItem extends StatelessWidget {
final Map<String, dynamic> item;
const _FollowUpItem({required this.item});
@override
Widget build(BuildContext context) {
final isCompleted = item['status'] == 'completed';
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: isCompleted ? const Color(0xFFDCFCE7) : const Color(0xFFFEFCE8),
borderRadius: BorderRadius.circular(8),
),
child: Text(
isCompleted ? '已完成' : '待就诊',
style: TextStyle(fontSize: 12, color: isCompleted ? const Color(0xFF43A047) : const Color(0xFFF59E0B)),
),
),
const SizedBox(width: 8),
Text(item['type']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
]),
const SizedBox(height: 12),
Text(item['hospital']?.toString() ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text('${item['department']} · ${item['date']}', style: TextStyle(fontSize: 14, color: Colors.grey[500])),
if ((item['notes']?.toString() ?? '').isNotEmpty) ...[
const SizedBox(height: 8),
Text(item['notes']?.toString() ?? '', style: TextStyle(fontSize: 14, color: Colors.grey[600])),
],
]),
);
}
}
/// 健康档案
@@ -166,9 +441,152 @@ class _EditProfilePageState extends ConsumerState<EditProfilePage> {
}
/// 健康日历
class HealthCalendarPage extends ConsumerWidget {
class HealthCalendarPage extends ConsumerStatefulWidget {
const HealthCalendarPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) => _empty(context, '健康日历', '暂无数据');
@override ConsumerState<HealthCalendarPage> createState() => _HealthCalendarPageState();
}
class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
DateTime _currentMonth = DateTime.now();
@override Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('健康日历'), centerTitle: true),
body: Column(children: [
_buildMonthHeader(),
_buildWeekdayHeader(),
_buildCalendarGrid(),
const SizedBox(height: 16),
_buildLegend(),
]),
);
}
Widget _buildMonthHeader() {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: const Icon(Icons.chevron_left, size: 32),
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month - 1)),
),
Text(
'${_currentMonth.year}${_currentMonth.month}',
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
IconButton(
icon: const Icon(Icons.chevron_right, size: 32),
onPressed: () => setState(() => _currentMonth = DateTime(_currentMonth.year, _currentMonth.month + 1)),
),
],
);
}
Widget _buildWeekdayHeader() {
const weekdays = ['', '', '', '', '', '', ''];
return Row(children: weekdays.map((day) => Expanded(
child: Center(child: Text(day, style: TextStyle(fontSize: 14, color: Colors.grey[500]))),
)).toList());
}
Widget _buildCalendarGrid() {
final firstDay = DateTime(_currentMonth.year, _currentMonth.month, 1);
final lastDay = DateTime(_currentMonth.year, _currentMonth.month + 1, 0);
final daysInMonth = lastDay.day;
final startWeekday = firstDay.weekday % 7;
final days = List.generate(42, (i) {
final dayIndex = i - startWeekday;
if (dayIndex < 0 || dayIndex >= daysInMonth) return null;
return dayIndex + 1;
});
return Expanded(
child: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: 42,
itemBuilder: (ctx, i) {
final day = days[i];
if (day == null) return const SizedBox();
return _buildDayCell(day);
},
),
);
}
Widget _buildDayCell(int day) {
final date = DateTime(_currentMonth.year, _currentMonth.month, day);
final today = DateTime.now();
final isToday = date.year == today.year && date.month == today.month && date.day == today.day;
final events = _getEvents(date);
return Container(
decoration: isToday ? BoxDecoration(
color: const Color(0xFF8B9CF7),
borderRadius: BorderRadius.circular(20),
) : null,
child: Stack(
alignment: Alignment.center,
children: [
Text(
'$day',
style: TextStyle(
fontSize: 16,
color: isToday ? Colors.white : Colors.black,
fontWeight: isToday ? FontWeight.w600 : FontWeight.normal,
),
),
if (events.isNotEmpty)
Positioned(
bottom: 4,
child: Row(children: events.map((type) => Container(
width: 6,
height: 6,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: _getEventColor(type),
borderRadius: BorderRadius.circular(3),
),
)).toList()),
),
],
),
);
}
List<String> _getEvents(DateTime date) {
final events = <String>[];
if (date.day == 5 || date.day == 12 || date.day == 19 || date.day == 26) events.add('medication');
if (date.day == 8 || date.day == 15 || date.day == 22 || date.day == 29) events.add('exercise');
if (date.day == 20) events.add('followup');
return events;
}
Color _getEventColor(String type) {
switch (type) {
case 'medication': return const Color(0xFF8B9CF7);
case 'exercise': return const Color(0xFF43A047);
case 'followup': return const Color(0xFFF59E0B);
default: return Colors.grey;
}
}
Widget _buildLegend() {
final items = [
{'color': const Color(0xFF8B9CF7), 'label': '用药提醒'},
{'color': const Color(0xFF43A047), 'label': '运动计划'},
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
];
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16),
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))),
const SizedBox(width: 4),
Text(item['label'] as String, style: TextStyle(fontSize: 12, color: Colors.grey[600])),
const SizedBox(width: 20),
])).toList()),
);
}
}
/// 静态文本页
@@ -176,8 +594,91 @@ class StaticTextPage extends ConsumerWidget {
final String type;
const StaticTextPage({super.key, required this.type});
@override Widget build(BuildContext context, WidgetRef ref) {
final titles = {'privacy': '隐私政策', 'terms': '服务协议', 'about': '关于'};
return Scaffold(appBar: AppBar(title: Text(titles[type] ?? '')), body: const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('内容后期填充', style: TextStyle(color: Color(0xFF999999))))));
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
final contents = {
'privacy': '''## 隐私政策
更新日期2026年1月1日
### 一、信息收集
我们收集以下类型的信息:
- 账户信息:手机号、昵称、头像(您主动提供)
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
- 用药信息:药品名称、剂量、服药时间等用药计划数据
- 饮食记录:通过拍照或手动录入的饮食数据
- 设备信息:设备型号、操作系统版本(用于适配优化)
- 日志信息App 使用情况、崩溃报告
### 二、信息使用
我们使用您的信息用于以下目的:
- 提供和改进健康管理服务
- AI 健康分析和个性化建议
- 用药提醒和复查通知推送
- App 功能优化和问题修复
### 三、信息保护
- 所有健康数据均采用 HTTPS 加密传输
- 数据存储于安全服务器,采用行业标准的加密措施
- 我们不会向任何第三方出售、出租或共享您的个人健康数据
- 医生仅可查看其签约患者的数据,且需经过您的授权
### 四、信息保留
- 对话记录保留 30 天后自动删除
- 您可以随时删除自己的健康数据和对话记录
- 账号注销后,所有数据将在 7 天内永久删除
### 五、您的权利
- 查看和导出您的个人数据
- 修改不准确的个人信息
- 删除不需要的数据
- 注销账号并清除所有数据
- 关闭推送通知
### 六、联系我们
如有任何关于隐私的问题,请联系:
邮箱privacy@healthbutler.com
电话400-xxx-xxxx''',
'about': '''## 关于健康管家
版本v1.0.0 (Build 20260101)
### 产品介绍
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
### 核心功能
- AI 智能问诊:基于大语言模型的健康咨询服务
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
- 智能用药管理AI 解析处方,自动生成用药计划和提醒
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
- 报告智能解读上传检查报告AI 自动提取指标并预解读
- 运动计划管理:制定和追踪每日运动目标
- 在线医生问诊:与签约医生进行远程咨询
### 开发团队
由专业医疗团队与 AI 技术团队联合打造。
### 技术支持
如遇到问题或有建议,请通过以下方式联系我们:
- 在线客服App 内「设置」→「意见反馈」
- 客服热线400-xxx-xxxx工作日 9:00-18:00
### 版权声明
© 2025-2026 健康管家团队。保留所有权利。
本软件受中华人民共和国著作权法保护。''',
};
return Scaffold(
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 14, height: 1.8, color: Color(0xFF333333))),
),
);
}
}

View File

@@ -0,0 +1,41 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
class AiAnalysisPage extends ConsumerWidget {
const AiAnalysisPage({super.key});
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]),
body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])),
);
}
Widget _buildTitle() {
return Row(mainAxisSize: MainAxisSize.min, children: [
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
]);
}
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
Widget _buildIndicators() {
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
}
Widget _indicatorCard(Map<String, dynamic> item) {
final isNormal = item['status'] == 'normal';
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
}
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动每周至少150分钟中等强度有氧运动', '保证充足睡眠每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
}

View File

@@ -1,25 +1,535 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:file_picker/file_picker.dart';
import '../../core/navigation_provider.dart';
final reportProvider = NotifierProvider<ReportNotifier, ReportState>(ReportNotifier.new);
class ReportState {
final List<ReportItem> reports;
final String? uploadingImage;
final bool isAnalyzing;
final ReportAnalysis? currentAnalysis;
ReportState({
this.reports = const [],
this.uploadingImage,
this.isAnalyzing = false,
this.currentAnalysis,
});
ReportState copyWith({
List<ReportItem>? reports,
String? uploadingImage,
bool? isAnalyzing,
ReportAnalysis? currentAnalysis,
}) {
return ReportState(
reports: reports ?? this.reports,
uploadingImage: uploadingImage ?? this.uploadingImage,
isAnalyzing: isAnalyzing ?? this.isAnalyzing,
currentAnalysis: currentAnalysis ?? this.currentAnalysis,
);
}
}
class ReportItem {
final String id;
final String title;
final String type;
final DateTime uploadedAt;
final String? imagePath;
final bool hasAnalysis;
ReportItem({
required this.id,
required this.title,
required this.type,
required this.uploadedAt,
this.imagePath,
this.hasAnalysis = false,
});
}
class ReportAnalysis {
final String reportId;
final String reportType;
final List<Indicator> indicators;
final String summary;
ReportAnalysis({
required this.reportId,
required this.reportType,
required this.indicators,
required this.summary,
});
}
class Indicator {
final String name;
final String value;
final String unit;
final String status;
final String? referenceRange;
Indicator({
required this.name,
required this.value,
required this.unit,
required this.status,
this.referenceRange,
});
}
class ReportNotifier extends Notifier<ReportState> {
static final _mockReports = [
ReportItem(
id: '1',
title: '血常规检查',
type: '血液检查',
uploadedAt: DateTime.now().subtract(const Duration(days: 3)),
hasAnalysis: true,
),
ReportItem(
id: '2',
title: '心电图报告',
type: '心电图',
uploadedAt: DateTime.now().subtract(const Duration(days: 7)),
hasAnalysis: true,
),
ReportItem(
id: '3',
title: '心脏超声',
type: '超声检查',
uploadedAt: DateTime.now().subtract(const Duration(days: 14)),
hasAnalysis: false,
),
];
@override
ReportState build() => ReportState(reports: _mockReports);
void uploadImage(String path) async {
state = state.copyWith(uploadingImage: path, isAnalyzing: true);
await Future.delayed(const Duration(seconds: 2));
final newReport = ReportItem(
id: '${DateTime.now().millisecondsSinceEpoch}',
title: '检查报告',
type: '影像报告',
uploadedAt: DateTime.now(),
imagePath: path,
hasAnalysis: true,
);
state = state.copyWith(
reports: [newReport, ...state.reports],
uploadingImage: null,
isAnalyzing: false,
currentAnalysis: _mockAnalysis,
);
}
void uploadFile(String path) async {
state = state.copyWith(isAnalyzing: true);
await Future.delayed(const Duration(seconds: 2));
final newReport = ReportItem(
id: '${DateTime.now().millisecondsSinceEpoch}',
title: '检查报告',
type: 'PDF文档',
uploadedAt: DateTime.now(),
hasAnalysis: true,
);
state = state.copyWith(
reports: [newReport, ...state.reports],
isAnalyzing: false,
currentAnalysis: _mockAnalysis,
);
}
void viewAnalysis(String reportId) {
state = state.copyWith(currentAnalysis: _mockAnalysis);
}
void clearAnalysis() {
state = state.copyWith(currentAnalysis: null);
}
}
final _mockAnalysis = ReportAnalysis(
reportId: '1',
reportType: '血常规检查',
indicators: [
Indicator(name: '白细胞计数', value: '7.5', unit: '×10^9/L', status: 'normal', referenceRange: '4.0-10.0'),
Indicator(name: '红细胞计数', value: '4.2', unit: '×10^12/L', status: 'normal', referenceRange: '3.5-5.5'),
Indicator(name: '血红蛋白', value: '128', unit: 'g/L', status: 'low', referenceRange: '130-175'),
Indicator(name: '血小板计数', value: '185', unit: '×10^9/L', status: 'normal', referenceRange: '100-300'),
Indicator(name: '中性粒细胞百分比', value: '65', unit: '%', status: 'normal', referenceRange: '50-70'),
Indicator(name: '淋巴细胞百分比', value: '28', unit: '%', status: 'normal', referenceRange: '20-40'),
],
summary: '整体来看,您的血常规检查基本正常。血红蛋白略低于正常范围,建议适当补充营养,多吃富含铁质的食物如红肉、动物肝脏等。如有疲劳、头晕等症状,建议咨询医生进一步检查。',
);
/// 报告列表页
class ReportListPage extends ConsumerWidget {
const ReportListPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '暂无报告', '可到「看报告」上传');
Widget build(BuildContext context, WidgetRef ref) {
final state = ref.watch(reportProvider);
if (state.isAnalyzing) {
return Scaffold(
appBar: AppBar(title: const Text('看报告')),
body: const Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
CircularProgressIndicator(color: Color(0xFF8B9CF7)),
SizedBox(height: 16),
Text('AI 正在分析报告...'),
]),
),
);
}
return Scaffold(
appBar: AppBar(
title: const Text('看报告'),
centerTitle: true,
),
floatingActionButton: _buildUploadButton(context, ref),
body: state.reports.isEmpty
? _buildEmptyState(context)
: ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: state.reports.length,
itemBuilder: (context, index) => _buildReportCard(context, ref, state.reports[index]),
),
);
}
Widget _buildUploadButton(BuildContext context, WidgetRef ref) {
return FloatingActionButton(
onPressed: () => _showUploadOptions(context, ref),
backgroundColor: const Color(0xFF8B9CF7),
child: const Icon(Icons.add),
);
}
void _showUploadOptions(BuildContext context, WidgetRef ref) {
showModalBottomSheet(
context: context,
builder: (ctx) => SafeArea(
child: Wrap(children: [
ListTile(
leading: const Icon(Icons.camera_alt),
title: const Text('拍照上传'),
onTap: () async {
Navigator.pop(ctx);
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85);
if (picked != null) {
ref.read(reportProvider.notifier).uploadImage(picked.path);
}
},
),
ListTile(
leading: const Icon(Icons.photo_library),
title: const Text('从相册选择'),
onTap: () async {
Navigator.pop(ctx);
final picker = ImagePicker();
final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85);
if (picked != null) {
ref.read(reportProvider.notifier).uploadImage(picked.path);
}
},
),
ListTile(
leading: const Icon(Icons.file_open),
title: const Text('上传PDF文件'),
onTap: () async {
Navigator.pop(ctx);
final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']);
if (result != null && result.files.isNotEmpty) {
ref.read(reportProvider.notifier).uploadFile(result.files.first.path!);
}
},
),
]),
),
);
}
Widget _buildEmptyState(BuildContext context) {
return Center(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(60),
),
child: const Icon(Icons.file_open, size: 48, color: Color(0xFF8B9CF7)),
),
const SizedBox(height: 20),
const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
const SizedBox(height: 8),
const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 14, color: Color(0xFF999999))),
]),
);
}
Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) {
return Container(
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(12),
),
child: _getReportIcon(report.type),
),
title: Text(report.title, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(report.type, style: TextStyle(fontSize: 14, color: Colors.grey[500])),
Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 12, color: Colors.grey[400])),
]),
trailing: report.hasAnalysis
? const Icon(Icons.check_circle, size: 20, color: Color(0xFF43A047))
: const Icon(Icons.arrow_forward_ios, size: 18, color: Color(0xFFCCCCCC)),
onTap: () {
ref.read(reportProvider.notifier).viewAnalysis(report.id);
pushRoute(ref, 'reportDetail', params: {'id': report.id});
},
),
);
}
Widget _getReportIcon(String type) {
final icons = {
'血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF8B9CF7)),
'心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF8B9CF7)),
'超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
'影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF8B9CF7)),
};
return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF8B9CF7));
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final diff = now.difference(date);
if (diff.inDays == 0) return '今天';
if (diff.inDays == 1) return '昨天';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${date.month}${date.day}';
}
}
/// 报告详情页
class ReportDetailPage extends ConsumerWidget {
final String id;
const ReportDetailPage({super.key, required this.id});
@override
Widget build(BuildContext context, WidgetRef ref) => _emptyPage(context, '报告详情', '报告 #$id');
Widget build(BuildContext context, WidgetRef ref) {
final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis));
if (analysis == null) {
return Scaffold(
appBar: AppBar(title: const Text('报告详情')),
body: const Center(child: Text('暂无分析数据')),
);
}
Widget _emptyPage(BuildContext context, String title, String subtitle) => Scaffold(
appBar: AppBar(title: Text(title)),
body: Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.description, size: 64, color: Colors.grey[300]),
const SizedBox(height: 12), Text(subtitle, style: Theme.of(context).textTheme.bodyMedium),
])),
return Scaffold(
appBar: AppBar(
title: const Text('报告解读'),
leading: IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () {
ref.read(reportProvider.notifier).clearAnalysis();
popRoute(ref);
},
),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
_buildReportHeader(analysis),
const SizedBox(height: 20),
_buildAnalysisSection(analysis),
const SizedBox(height: 20),
_buildSummarySection(analysis),
const SizedBox(height: 20),
SizedBox(
width: double.infinity,
height: 48,
child: OutlinedButton.icon(
onPressed: () {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
);
},
icon: const Icon(Icons.image),
label: const Text('查看原始图片'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF8B9CF7),
side: const BorderSide(color: Color(0xFF8B9CF7)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
),
),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
height: 48,
child: ElevatedButton(
onPressed: () => pushRoute(ref, 'aiAnalysis'),
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
child: const Text('查看 AI 智能解读'),
),
),
const SizedBox(height: 30),
]),
),
);
}
Widget _buildReportHeader(ReportAnalysis analysis) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFF0F2FF),
borderRadius: BorderRadius.circular(16),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('📋', style: TextStyle(fontSize: 24)),
const SizedBox(width: 12),
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(analysis.reportType, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
const Text('AI 预解读结果', style: TextStyle(fontSize: 14, color: Color(0xFF666666))),
]),
]),
]),
);
}
Widget _buildAnalysisSection(ReportAnalysis analysis) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(children: [
const Text('🧪', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
]),
),
Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xFFFFF3E0),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFFFE0B2)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.info_outline, size: 16, color: Color(0xFFE65100)),
const SizedBox(width: 6),
const Text(
'AI 预解读 · 待医生确认',
style: TextStyle(fontSize: 13, color: Color(0xFFE65100), fontWeight: FontWeight.w500),
),
],
),
),
const SizedBox(height: 12),
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
]),
);
}
Widget _buildIndicatorRow(Indicator ind) {
Color statusColor;
IconData statusIcon;
switch (ind.status) {
case 'high':
statusColor = const Color(0xFFE53935);
statusIcon = Icons.arrow_upward;
break;
case 'low':
statusColor = const Color(0xFFF9A825);
statusIcon = Icons.arrow_downward;
break;
default:
statusColor = const Color(0xFF43A047);
statusIcon = Icons.check_circle;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(vertical: 12),
decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: Color(0xFFF0F0F0)))),
child: Row(children: [
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(ind.name, style: const TextStyle(fontSize: 14)),
if (ind.referenceRange != null)
Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 12, color: Colors.grey[400])),
]),
),
const SizedBox(width: 16),
Column(children: [
Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: statusColor)),
Icon(statusIcon, size: 16, color: statusColor),
]),
]),
);
}
Widget _buildSummarySection(ReportAnalysis analysis) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFEF3C7),
borderRadius: BorderRadius.circular(16),
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
const Text('💡', style: TextStyle(fontSize: 20)),
const SizedBox(width: 8),
const Text('综合解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFFD97706))),
]),
const SizedBox(height: 12),
Text(analysis.summary, style: const TextStyle(fontSize: 14, color: Color(0xFF92400E), height: 1.6)),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
),
child: const Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 13, color: Color(0xFFD97706))),
),
]),
);
}
}

View File

@@ -0,0 +1,216 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
// ── 通知偏好状态 ──
final notificationPrefsProvider = NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
NotificationPrefsNotifier.new,
);
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
@override
Map<String, bool> build() {
// TODO: 从 SQLite 读取持久化值,此处先用默认值
return {
'medication': true,
'healthAlert': true,
'followUp': true,
'aiReply': false,
'dndEnabled': false,
'pushEnabled': true,
'dndStart': false, // 占位:实际用 TimeOfDay
'dndEnd': false,
};
}
void toggle(String key) {
state = {...state, key: !state[key]!};
// TODO: 持久化到 SQLite
}
void setDndStart(TimeOfDay time) {
state = {...state, 'dndStart': true}; // 简化存储
}
void setDndEnd(TimeOfDay time) {
state = {...state, 'dndEnd': true};
}
}
// ── 页面 ──
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(notificationPrefsProvider);
final dndOn = prefs['dndEnabled'] ?? false;
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1A1A1A)),
onPressed: () => popRoute(ref),
),
title: const Text('消息通知', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
centerTitle: true,
),
body: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── 推送总开关 ──
_SectionTitle(title: '推送通知'),
_SwitchTile(
title: '允许推送通知',
subtitle: '关闭后将不再收到任何系统推送',
value: prefs['pushEnabled'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('pushEnabled'),
),
const SizedBox(height: 24),
// ── 各类通知开关 ──
_SectionTitle(title: '通知类型'),
_SwitchTile(
icon: Icons.medication_rounded,
iconBg: const Color(0xFFFFF3E0),
iconColor: const Color(0xFFFF9800),
title: '用药提醒',
subtitle: '服药时间到达时提醒您',
value: prefs['medication'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('medication'),
),
_SwitchTile(
icon: Icons.warning_amber_rounded,
iconBg: const Color(0xFFFFEBEE),
iconColor: const Color(0xFFE53935),
title: '健康异常提醒',
subtitle: '检测到数据异常时及时通知',
value: prefs['healthAlert'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('healthAlert'),
),
_SwitchTile(
icon: Icons.event_available_rounded,
iconBg: const Color(0xFFE8F5E9),
iconColor: const Color(0xFF4CAF50),
title: '复查日期提醒',
subtitle: '复查日前一天提醒您预约',
value: prefs['followUp'] ?? true,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('followUp'),
),
_SwitchTile(
icon: Icons.smart_toy_outlined,
iconBg: const Color(0xFFF3E5F5),
iconColor: const Color(0xFF8B9CF7),
title: 'AI 回复通知',
subtitle: 'AI 助手回复时发送通知',
value: prefs['aiReply'] ?? false,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('aiReply'),
),
const SizedBox(height: 24),
// ── 免打扰时段 ──
_SectionTitle(title: '免打扰时段'),
_SwitchTile(
title: '开启免打扰模式',
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知',
value: dndOn,
onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('dndEnabled'),
),
if (dndOn) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 4),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked);
})),
Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 16, color: Colors.grey[400]))),
Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async {
final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0));
if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked);
})),
]),
),
const SizedBox(height: 8),
],
const SizedBox(height: 40),
],
),
),
);
}
}
// ── 子组件 ──
class _SectionTitle extends StatelessWidget {
final String title;
const _SectionTitle({required this.title});
@override
Widget build(BuildContext context) {
return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF999999))));
}
}
class _SwitchTile extends StatelessWidget {
final IconData? icon;
final Color? iconBg;
final Color? iconColor;
final String title;
final String? subtitle;
final bool value;
final ValueChanged<bool> onChanged;
const _SwitchTile({
this.icon, this.iconBg, this.iconColor,
required this.title,
this.subtitle,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)),
child: Row(children: [
if (icon != null) ...[
Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: iconColor)),
const SizedBox(width: 12),
],
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), fontWeight: FontWeight.w500)),
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
])),
Switch(value: value, onChanged: onChanged, activeThumbColor: const Color(0xFF8B9CF7), activeTrackColor: const Color(0xFFC5BFFF)),
]),
);
}
}
class _TimeButton extends StatelessWidget {
final String label;
final String time;
final VoidCallback onTap;
const _TimeButton({required this.label, required this.time, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(onTap: onTap, child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(10)),
child: Column(children: [Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))]),
));
}
}

View File

@@ -3,65 +3,83 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
/// 设置页
class SettingsPage extends ConsumerWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('设置')),
body: ListView(children: [
_SetItem(icon: Icons.shield, title: '隐私保护中心', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
_SetItem(icon: Icons.notifications, title: '通知偏好', onTap: () => pushRoute(ref, 'notificationPrefs')),
_SetItem(icon: Icons.text_fields, title: '字体大小', trailing: _FontSlider()),
_SetItem(icon: Icons.article, title: '协议与公告', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'terms'})),
_SetItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
const Divider(),
_SetItem(icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), onTap: () async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('退出登录'), content: const Text('确定退出?'),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
}),
]),
@override Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
backgroundColor: const Color(0xFFF8F9FC),
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
title: const Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
centerTitle: true,
),
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [
const SizedBox(height: 12),
_SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')),
_SetItem(icon: Icons.data_usage_outlined, title: '数据导出', onTap: () {}),
_SetItem(icon: Icons.text_fields_outlined, title: '字体大小', trailingText: 'v1.0.0', onTap: () {}),
_SetItem(icon: Icons.cleaning_services_outlined, title: '清除缓存', subtitle: '73.2 MB', onTap: () {}),
_SetItem(icon: Icons.info_outline, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
_SetItem(icon: Icons.shield_outlined, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
const SizedBox(height: 30),
GestureDetector(
onTap: () async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('退出登录'),
content: const Text('确定退出?'),
actions: [
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
],
),
);
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
},
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24),
height: 50,
alignment: Alignment.center,
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)),
child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)),
),
),
]))),
);
}
}
class _SetItem extends StatelessWidget {
final IconData icon; final String title; final VoidCallback? onTap; final Widget? trailing; final Color? textColor;
const _SetItem({required this.icon, required this.title, this.onTap, this.trailing, this.textColor});
@override
Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor)), trailing: trailing ?? const Icon(Icons.chevron_right, size: 20), onTap: onTap);
}
final IconData icon;
final String title;
final String? subtitle;
final String? trailingText;
final VoidCallback? onTap;
class _FontSlider extends StatefulWidget {
@override State<_FontSlider> createState() => _FontSliderState();
}
class _FontSliderState extends State<_FontSlider> {
double _value = 1.0;
@override Widget build(BuildContext context) => SizedBox(width: 120, child: Slider(value: _value, min: 0.8, max: 1.6, divisions: 8, label: '${_value.toStringAsFixed(1)}x', onChanged: (v) => setState(() => _value = v)));
}
const _SetItem({required this.icon, required this.title, this.subtitle, this.trailingText, this.onTap});
/// 通知偏好页
class NotificationPrefsPage extends ConsumerWidget {
const NotificationPrefsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
appBar: AppBar(title: const Text('通知偏好')),
body: ListView(children: [
_SwitchTile(icon: Icons.medication, title: '用药提醒'),
_SwitchTile(icon: Icons.calendar_month, title: '复查提醒'),
_SwitchTile(icon: Icons.chat, title: '医生回复'),
_SwitchTile(icon: Icons.warning_amber, title: '异常警告'),
@override Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
behavior: HitTestBehavior.opaque,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
decoration: BoxDecoration(color: Colors.white),
child: Row(children: [
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
const SizedBox(width: 12),
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 13, color: Colors.grey[500]))])),
if (trailingText != null && trailingText!.isNotEmpty) Text(trailingText!, style: TextStyle(fontSize: 14, color: Colors.grey[400])),
if (trailingText == null || trailingText!.isEmpty) const SizedBox(),
Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]),
]),
),
);
}
class _SwitchTile extends StatefulWidget {
final IconData icon; final String title;
const _SwitchTile({required this.icon, required this.title});
@override State<_SwitchTile> createState() => _SwitchTileState();
}
class _SwitchTileState extends State<_SwitchTile> {
bool _on = true;
@override Widget build(BuildContext context) => SwitchListTile(secondary: Icon(widget.icon), title: Text(widget.title), value: _on, onChanged: (v) => setState(() => _on = v));
}

View File

@@ -1,18 +1,27 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'data_providers.dart';
import '../utils/sse_handler.dart';
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
class ChatMessage {
final String id;
final String role;
String content;
final DateTime createdAt;
ChatMessage(
{required this.id,
MessageType type;
final Map<String, dynamic>? metadata;
ChatMessage({
required this.id,
required this.role,
required this.content,
required this.createdAt});
required this.createdAt,
this.type = MessageType.text,
this.metadata,
});
bool get isUser => role == 'user';
}
@@ -23,26 +32,41 @@ class ChatState {
final List<ChatMessage> messages;
final String? conversationId;
final bool isStreaming;
final String? noticeText;
final String? thinkingText;
const ChatState({
this.activeAgent = ActiveAgent.default_,
this.messages = const [],
this.conversationId,
this.isStreaming = false,
this.noticeText,
this.thinkingText,
});
ChatState copyWith({ActiveAgent? activeAgent, List<ChatMessage>? messages,
String? conversationId, bool? isStreaming, String? noticeText,
bool clearNotice = false}) =>
String? conversationId, bool? isStreaming, String? thinkingText}) =>
ChatState(
activeAgent: activeAgent ?? this.activeAgent,
messages: messages ?? this.messages,
conversationId: conversationId ?? this.conversationId,
isStreaming: isStreaming ?? this.isStreaming,
noticeText: clearNotice ? null : (noticeText ?? this.noticeText),
thinkingText: thinkingText ?? this.thinkingText,
);
}
class ConversationItem {
final String id;
final String title;
final String lastMessage;
final DateTime updatedAt;
final ActiveAgent agent;
ConversationItem({
required this.id,
required this.title,
required this.lastMessage,
required this.updatedAt,
required this.agent,
});
}
class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
@override
ActiveAgent? build() => null;
@@ -52,16 +76,120 @@ class SelectedAgentNotifier extends Notifier<ActiveAgent?> {
final selectedAgentProvider =
NotifierProvider<SelectedAgentNotifier, ActiveAgent?>(SelectedAgentNotifier.new);
final chatProvider = NotifierProvider<ChatNotifier, ChatState>(ChatNotifier.new);
final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) async {
final api = ref.watch(apiClientProvider);
final token = await api.accessToken;
if (token == null) return [];
try {
final res = await api.get('/api/conversations');
final list = res.data['data'] as List? ?? [];
return list.map((item) {
final data = item as Map<String, dynamic>;
return ConversationItem(
id: data['id']?.toString() ?? '',
title: data['title']?.toString() ?? '对话',
lastMessage: data['lastMessage']?.toString() ?? '',
updatedAt: DateTime.parse(data['updatedAt']?.toString() ?? DateTime.now().toIso8601String()),
agent: _parseAgent(data['agentType']?.toString()),
);
}).toList();
} catch (_) {
return [];
}
});
ActiveAgent _parseAgent(String? type) {
switch (type?.toLowerCase()) {
case 'consultation': return ActiveAgent.consultation;
case 'health': return ActiveAgent.health;
case 'diet': return ActiveAgent.diet;
case 'medication': return ActiveAgent.medication;
case 'report': return ActiveAgent.report;
case 'exercise': return ActiveAgent.exercise;
default: return ActiveAgent.default_;
}
}
class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription;
@override
ChatState build() => const ChatState();
ChatState build() {
// 首次加载时插入今日任务卡片作为第一条消息
Future.microtask(() => insertTaskCard());
return const ChatState();
}
void insertTaskCard() {
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
state = state.copyWith(messages: [ChatMessage(
id: 'task_card',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.taskCard,
), ...state.messages]);
}
void setAgent(ActiveAgent a) {
_subscription?.cancel();
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a);
state = state.copyWith(activeAgent: a);
}
void insertAgentWelcome(ActiveAgent agent) {
state = state.copyWith(messages: [...state.messages, ChatMessage(
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.agentWelcome,
metadata: {'agent': agent.name},
)]);
}
Future<void> sendImage(String imagePath, String text) async {
final file = File(imagePath);
if (!await file.exists()) return;
// 先显示用户消息(本地显示图片路径)
final userMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}',
role: 'user',
content: text.isNotEmpty ? text : '[图片]',
createdAt: DateTime.now(),
metadata: {'localImagePath': imagePath},
);
state = state.copyWith(messages: [...state.messages, userMsg]);
// 异步上传图片
String? uploadedUrl;
try {
final api = ref.read(apiClientProvider);
uploadedUrl = await api.uploadFile('/api/upload', file);
} catch (_) {
// 上传失败:保留本地路径,仍然可以本地显示
}
// 更新消息元数据(保留本地路径 + 添加远程URL
final updatedMsgs = state.messages.toList();
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
if (idx >= 0) {
final meta = <String, dynamic>{'localImagePath': imagePath};
if (uploadedUrl != null) meta['imageUrl'] = uploadedUrl;
updatedMsgs[idx] = ChatMessage(
id: userMsg.id,
role: 'user',
content: userMsg.content,
createdAt: userMsg.createdAt,
metadata: meta,
);
state = state.copyWith(messages: updatedMsgs);
}
// 将图片 URL 作为消息内容发送给 AI
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
await _sendToAI(msgWithImage);
}
Future<void> sendMessage(String text) async {
@@ -76,6 +204,10 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith(
messages: [...state.messages, userMsg], isStreaming: true);
await _sendToAI(text);
}
Future<void> _sendToAI(String text) async {
final aiMsg = ChatMessage(
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
role: 'assistant',
@@ -83,6 +215,8 @@ class ChatNotifier extends Notifier<ChatState> {
createdAt: DateTime.now(),
);
state = state.copyWith(isStreaming: true);
try {
final token = await ref.read(apiClientProvider).accessToken;
if (token == null) {
@@ -119,7 +253,7 @@ class ChatNotifier extends Notifier<ChatState> {
),
],
isStreaming: false,
clearNotice: true,
thinkingText: null,
);
}
@@ -130,9 +264,17 @@ class ChatNotifier extends Notifier<ChatState> {
state = state.copyWith(conversationId: j['data']?.toString());
case 'answer':
aiMsg.content += (j['data'] as String?) ?? '';
final messageType = j['type'] as String? ?? 'text';
aiMsg.type = _parseMessageType(messageType);
state = state.copyWith(thinkingText: null);
_update(aiMsg);
case 'notice':
state = state.copyWith(noticeText: j['message'] as String?);
state = state.copyWith(thinkingText: j['message'] as String?);
case 'tool_result':
final tool = j['tool'] as String? ?? '';
if (tool == 'record_health_data') {
ref.invalidate(latestHealthProvider);
}
case 'status':
_done(aiMsg);
case 'error':
@@ -140,6 +282,18 @@ class ChatNotifier extends Notifier<ChatState> {
}
}
MessageType _parseMessageType(String type) {
switch (type) {
case 'data_confirm': return MessageType.dataConfirm;
case 'medication_confirm': return MessageType.medicationConfirm;
case 'diet_analysis': return MessageType.dietAnalysis;
case 'report_analysis': return MessageType.reportAnalysis;
case 'quick_options': return MessageType.quickOptions;
case 'agent_welcome': return MessageType.agentWelcome;
default: return MessageType.text;
}
}
void _update(ChatMessage m) {
final u = state.messages.toList();
final i = u.indexWhere((x) => x.id == m.id);
@@ -154,6 +308,6 @@ class ChatNotifier extends Notifier<ChatState> {
void _done(ChatMessage m) {
final u = state.messages.toList();
if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m);
state = state.copyWith(messages: u, isStreaming: false, clearNotice: true);
state = state.copyWith(messages: u, isStreaming: false, thinkingText: null);
}
}

View File

@@ -2,6 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import '../services/health_service.dart';
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider));
});
/// 健康数据服务
final healthServiceProvider = Provider<HealthService>((ref) {
return HealthService(ref.watch(apiClientProvider));
@@ -23,28 +27,63 @@ final consultationServiceProvider = Provider<ConsultationService>((ref) {
return ConsultationService(ref.watch(apiClientProvider));
});
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
return ExerciseService(ref.watch(apiClientProvider));
});
/// 最新健康数据 Provider
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(healthServiceProvider);
return service.getLatest();
});
/// AI 录入数据后调用,刷新侧边栏
void refreshHealthData(WidgetRef ref) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
}
/// 用药列表 Provider
final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getList();
});
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
/// 医生列表 Provider
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
final service = ref.watch(consultationServiceProvider);
return service.getDoctors();
try {
return await service.getDoctors().timeout(const Duration(seconds: 8));
} catch (_) {
return _fallbackDoctors;
}
});
const _fallbackDoctors = [
{
'id': 'doc_1',
'name': '张医生',
'title': '主任医师',
'department': '心内科',
'introduction': '擅长冠心病、高血压术后管理20年临床经验',
},
{
'id': 'doc_2',
'name': '李医生',
'title': '副主任医师',
'department': '内分泌科',
'introduction': '擅长糖尿病、甲状腺疾病管理15年临床经验',
},
{
'id': 'doc_3',
'name': '王医生',
'title': '主治医师',
'department': '营养科',
'introduction': '擅长术后营养指导、饮食方案制定10年临床经验',
},
];
/// 问诊配额 Provider
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
final service = ref.watch(consultationServiceProvider);
@@ -54,5 +93,30 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
/// 当前运动计划 Provider
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan();
try {
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
} catch (_) {
final today = DateTime.now();
final monday = today.subtract(Duration(days: today.weekday - 1));
return {
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
'items': List.generate(7, (i) => {
'id': 'local_$i',
'dayOfWeek': i,
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
'isRestDay': i == 2 || i == 5,
'isCompleted': false,
}),
};
}
});
/// 拍照/相册直接触发(无需跳转页面)
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
class CameraActionNotifier extends Notifier<String?> {
@override String? build() => null;
void trigger(String action) => state = action;
void clear() => state = null;
}

View File

@@ -83,6 +83,12 @@ class MedicationService {
Future<void> confirm(String id) async {
await _api.post('/api/medications/$id/confirm');
}
Future<List<Map<String, dynamic>>> getReminders() async {
final res = await _api.get('/api/medications/reminders');
final list = res.data['data'] as List? ?? [];
return list.cast<Map<String, dynamic>>();
}
}
/// 饮食服务

View File

@@ -43,16 +43,16 @@ class AgentBar extends ConsumerWidget {
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
padding: const EdgeInsets.symmetric(horizontal: 14),
decoration: BoxDecoration(
color: isSelected ? const Color(0xFF635BFF) : Colors.white,
border: Border.all(color: const Color(0xFF635BFF)),
color: isSelected ? const Color(0xFF8B9CF7) : Colors.white,
border: Border.all(color: const Color(0xFF8B9CF7)),
borderRadius: BorderRadius.circular(24),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF635BFF)),
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF8B9CF7)),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
],
),
),

View File

@@ -3,8 +3,9 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../providers/chat_provider.dart';
/// 侧滑抽屉——健康概览 + 历史对话 + 菜单
/// 侧滑抽屉——彩色分区卡片式设计
class HealthDrawer extends ConsumerWidget {
const HealthDrawer({super.key});
@@ -13,107 +14,509 @@ class HealthDrawer extends ConsumerWidget {
final auth = ref.watch(authProvider);
final user = auth.user;
final latestHealth = ref.watch(latestHealthProvider);
final conversations = ref.watch(conversationListProvider);
return Drawer(
width: MediaQuery.of(context).size.width * 0.82,
backgroundColor: const Color(0xFFFAFBFE),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 用户信息
Container(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: ListView(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
children: [
// ════════════ 用户区 ════════════
_SectionCard(
color: const Color(0xFF635BFF),
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
child: Padding(
padding: const EdgeInsets.all(18),
child: Row(children: [
GestureDetector(
onTap: () => pushRoute(ref, 'profile'),
child: CircleAvatar(
radius: 28,
backgroundColor: const Color(0xFFEDEBFF),
child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary),
child: Container(
width: 52, height: 52,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
shape: BoxShape.circle,
border: Border.all(color: Colors.white30, width: 1.5),
),
child: user?.avatarUrl != null
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
: _defaultAvatar(),
),
),
const SizedBox(height: 12),
Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium),
if (user != null) const SizedBox(height: 4),
Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium),
const SizedBox(width: 14),
Expanded(child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)),
const SizedBox(height: 2),
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)),
],
)),
Icon(Icons.chevron_right, size: 18, color: Colors.white54),
]),
),
),
const SizedBox(height: 10),
// ════════════ 健康概览区 ════════════
_SectionCard(
color: const Color(0xFFE8F0FE),
gradientColors: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF635BFF).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)),
SizedBox(width: 4),
Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
]),
),
const Spacer(),
GestureDetector(
onTap: () => pushRoute(ref, 'trend'),
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 11, color: Color(0xFF888888)))),
),
]),
),
latestHealth.when(
data: (data) => Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
],
),
),
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
const Divider(),
// 健康概览——接真实数据
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF))))),
error: (Object err, StackTrace st) => Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: [
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: '--', accentColor: const Color(0xFFFF6B6B)),
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: '--', accentColor: const Color(0xFFFF9F43)),
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: '--', accentColor: const Color(0xFF26C281)),
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: '--', accentColor: const Color(0xFF4D96FF)),
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: '--', accentColor: const Color(0xFFA55EEA)),
],
),
latestHealth.when(
data: (data) => Column(children: [
_HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
_HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
]),
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
error: (_, _) => Column(children: [
_HealthMetric(icon: Icons.favorite, label: '血压', value: '--'),
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'),
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'),
_HealthMetric(icon: Icons.air, label: '血氧', value: '--'),
),
),
],
),
),
const SizedBox(height: 10),
// ════════════ 功能区(横向排布)════════════
_SectionCard(
color: const Color(0xFFFDF6EC),
gradientColors: null,
child: Padding(
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFFF0A060).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)),
SizedBox(width: 4),
Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))),
]),
),
const Divider(),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
]),
),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')),
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')),
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')),
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')),
],
),
],
),
),
),
const Expanded(child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14)))),
const Divider(),
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
title: const Text('退出登录'), content: const Text('确定退出?'),
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
}),
const SizedBox(height: 10),
// ════════════ 历史对话区 ════════════
_SectionCard(
color: const Color(0xFFF0F4FF),
gradientColors: null,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
child: Row(children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: const Color(0xFF4D96FF).withAlpha(15),
borderRadius: BorderRadius.circular(6),
),
child: const Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.history_rounded, size: 13, color: Color(0xFF4D96FF)),
SizedBox(width: 4),
Text('历史对话', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4D96FF))),
]),
),
const Spacer(),
GestureDetector(
onTap: () => ref.invalidate(conversationListProvider),
child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))),
),
]),
),
_buildConversationList(ref, conversations),
],
),
),
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: 18, color: Color(0xFF666666)),
),
const SizedBox(width: 12),
const Expanded(child: Text('设置', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF333333)))),
const Icon(Icons.chevron_right, size: 16, color: Color(0xFFCCCCCC)),
]),
),
),
),
),
const SizedBox(height: 6),
],
),
),
);
}
static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70);
String _bpText(dynamic bp) {
if (bp == null) return '--';
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
return '--';
}
String _metricText(dynamic metric, String unit) {
String _metricVal(dynamic metric) {
if (metric == null) return '--';
if (metric is Map) {
final v = metric['value'];
return v != null ? '$v $unit' : '--';
}
return '--';
}
if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; }
return metric.toString();
}
class _DrawerItem extends StatelessWidget {
final IconData icon; final String label; final VoidCallback onTap;
const _DrawerItem({required this.icon, required this.label, required this.onTap});
@override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true);
}
class _HealthMetric extends StatelessWidget {
final IconData icon; final String label; final String value; final VoidCallback? onTap;
const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap});
@override Widget build(BuildContext context) => ListTile(
leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)),
title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))),
dense: true,
onTap: onTap,
Widget _buildConversationList(WidgetRef ref, AsyncValue<List<ConversationItem>> conversations) {
return conversations.when(
data: (items) {
if (items.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('暂无历史对话', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
),
);
}
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 14),
child: Column(
mainAxisSize: MainAxisSize.min,
children: items.map((item) => _ConversationItem(item: item)).toList(),
),
);
},
loading: () => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF4D96FF)),
),
),
),
error: (Object err, StackTrace st) => const Padding(
padding: EdgeInsets.symmetric(vertical: 16),
child: Center(
child: Text('加载失败', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 分区卡片容器 —— 带圆角、阴影和微动效
// ═══════════════════════════════════════════════════════════════
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 _MetricTile extends StatelessWidget {
final IconData icon;
final String label;
final String value;
final String? unit;
final Color accentColor;
final VoidCallback? onTap;
const _MetricTile({
required this.icon,
required this.label,
required this.value,
this.unit,
required this.accentColor,
this.onTap,
});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(),
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: accentColor.withAlpha(30)),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 28, height: 28,
decoration: BoxDecoration(
color: accentColor.withAlpha(15),
borderRadius: BorderRadius.circular(8),
),
child: Icon(icon, size: 15, color: accentColor),
),
const SizedBox(height: 4),
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))),
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 功能按钮(横向)
// ═══════════════════════════════════════════════════════════════
class _FeatureChip extends StatelessWidget {
final IconData icon;
final String label;
final Color bgColor;
final Color iconColor;
final VoidCallback onTap;
const _FeatureChip({
required this.icon,
required this.label,
required this.bgColor,
required this.iconColor,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Material(
color: Colors.transparent,
child: InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(12),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration(
color: bgColor,
borderRadius: BorderRadius.circular(12),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(icon, size: 17, color: iconColor),
const SizedBox(width: 6),
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))),
]),
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════
// 历史对话项
// ═══════════════════════════════════════════════════════════════
class _ConversationItem extends StatelessWidget {
final ConversationItem item;
const _ConversationItem({required this.item});
@override
Widget build(BuildContext context) {
final colors = _conversationColors(item.agent);
return Container(
margin: const EdgeInsets.symmetric(vertical: 2),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(10),
border: Border.all(color: colors.$1.withAlpha(80)),
),
child: ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
leading: Container(
width: 32, height: 32,
decoration: BoxDecoration(
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
borderRadius: BorderRadius.circular(8),
),
child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2),
),
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])),
const SizedBox(height: 2),
Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]),
],
),
dense: true,
visualDensity: VisualDensity.compact,
),
);
}
IconData _getAgentIcon(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => Icons.health_and_safety_outlined,
ActiveAgent.diet => Icons.restaurant_outlined,
ActiveAgent.medication => Icons.medication_outlined,
ActiveAgent.report => Icons.description_outlined,
ActiveAgent.exercise => Icons.directions_run_outlined,
ActiveAgent.consultation => Icons.chat_bubble_outline,
_ => Icons.chat_bubble_outline,
};
}
String _formatTime(DateTime time) {
final now = DateTime.now();
final diff = now.difference(time);
if (diff.inMinutes < 60) return '${diff.inMinutes}分钟前';
if (diff.inHours < 24) return '${diff.inHours}小时前';
if (diff.inDays < 7) return '${diff.inDays}天前';
return '${time.month}/${time.day}';
}
}
(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) {
return switch (agent) {
ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)),
ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)),
ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)),
ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)),
ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)),
ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)),
_ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)),
};
}
class _ColorSet extends Color {
const _ColorSet(int super.value);
}

View File

@@ -230,6 +230,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "6.0.0"
flutter_markdown:
dependency: "direct main"
description:
name: flutter_markdown
sha256: "08fb8315236099ff8e90cb87bb2b935e0a724a3af1623000a9cec930468e0f27"
url: "https://pub.dev"
source: hosted
version: "0.7.7+1"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
@@ -416,6 +424,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "1.3.0"
markdown:
dependency: transitive
description:
name: markdown
sha256: ee85086ad7698b42522c6ad42fe195f1b9898e4d974a1af4576c1a3a176cada9
url: "https://pub.dev"
source: hosted
version: "7.3.1"
matcher:
dependency: transitive
description:

View File

@@ -27,6 +27,9 @@ dependencies:
image_picker: ^1.0.0
file_picker: ^10.3.7
# Markdown 渲染
flutter_markdown: ^0.7.0
# 推送(后期集成)
# jpush_flutter: ^3.4.5

Binary file not shown.

After

Width:  |  Height:  |  Size: 602 KiB

Some files were not shown because too many files have changed in this diff Show More