Compare commits
3 Commits
c6395ea9b4
...
36ad334643
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36ad334643 | ||
|
|
7b898f8660 | ||
|
|
78573eaa5f |
@@ -124,6 +124,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config)
|
|||||||
var request = new ChatCompletionRequest
|
var request = new ChatCompletionRequest
|
||||||
{
|
{
|
||||||
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false,
|
||||||
|
Temperature = 0.7f, TopP = 0.8f,
|
||||||
};
|
};
|
||||||
|
|
||||||
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
var json = JsonSerializer.Serialize(request, _jsonOptions);
|
||||||
|
|||||||
@@ -164,6 +164,7 @@ public sealed class ChatCompletionRequest
|
|||||||
public bool Stream { get; set; }
|
public bool Stream { get; set; }
|
||||||
public int MaxTokens { get; set; } = 2048;
|
public int MaxTokens { get; set; } = 2048;
|
||||||
public float Temperature { get; set; } = 0.7f;
|
public float Temperature { get; set; } = 0.7f;
|
||||||
|
public float? TopP { get; set; }
|
||||||
public List<ToolDefinition>? Tools { get; set; }
|
public List<ToolDefinition>? Tools { get; set; }
|
||||||
public string? ToolChoice { get; set; }
|
public string? ToolChoice { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -267,22 +267,17 @@ public static class AiChatEndpoints
|
|||||||
|
|
||||||
// 压缩图片后转 base64(VLM API 有请求体大小限制)
|
// 压缩图片后转 base64(VLM API 有请求体大小限制)
|
||||||
var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}");
|
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 compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct);
|
||||||
var base64 = Convert.ToBase64String(compressedBytes);
|
var base64 = Convert.ToBase64String(compressedBytes);
|
||||||
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
imageUrls.Add($"data:image/jpeg;base64,{base64}");
|
||||||
}
|
}
|
||||||
|
|
||||||
var prompt = """
|
var prompt = "精准识别用户提供的食物图片,提取并返回详细信息,包括但不限于食物名称、具体份量及对应热量值。系统应确保识别结果的准确性和清晰度,以便为病人的饮食管理提供可靠数据支持。";
|
||||||
识别图片中所有食物,用中文名称,只返回JSON:
|
|
||||||
{
|
|
||||||
"foods": [{"name":"食物名","portion":"份量","calories":整数}]
|
|
||||||
}
|
|
||||||
""";
|
|
||||||
|
|
||||||
try
|
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 ?? "{}";
|
var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}";
|
||||||
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
return Results.Ok(new { code = 0, data = result, message = (string?)null });
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
@@ -1,283 +0,0 @@
|
|||||||
"""
|
|
||||||
健康管家 - 全流程端到端测试
|
|
||||||
模拟真实用户操作:注册→登录→各Agent对话→数据录入→查询验证
|
|
||||||
"""
|
|
||||||
import urllib.request, urllib.parse, json, sys, time, os
|
|
||||||
|
|
||||||
BASE = "http://localhost:5000"
|
|
||||||
PASSED = 0
|
|
||||||
FAILED = 0
|
|
||||||
TOKEN = None
|
|
||||||
|
|
||||||
def api(method, path, data=None, token=None, files=None):
|
|
||||||
"""调用后端 API"""
|
|
||||||
url = f"{BASE}{path}"
|
|
||||||
headers = {"Content-Type": "application/json"}
|
|
||||||
if token:
|
|
||||||
headers["Authorization"] = f"Bearer {token}"
|
|
||||||
|
|
||||||
body = None
|
|
||||||
if data:
|
|
||||||
body = json.dumps(data, ensure_ascii=False).encode("utf-8")
|
|
||||||
|
|
||||||
req = urllib.request.Request(url, data=body, headers=headers, method=method)
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req, timeout=30)
|
|
||||||
return json.loads(resp.read().decode("utf-8"))
|
|
||||||
except Exception as e:
|
|
||||||
return {"error": str(e), "code": -1}
|
|
||||||
|
|
||||||
def check(name, condition, detail=""):
|
|
||||||
global PASSED, FAILED
|
|
||||||
if condition:
|
|
||||||
PASSED += 1
|
|
||||||
print(f" [PASS] {name}")
|
|
||||||
else:
|
|
||||||
FAILED += 1
|
|
||||||
print(f" [FAIL] {name} {detail}")
|
|
||||||
|
|
||||||
def login(phone="13800000001"):
|
|
||||||
"""发送验证码 + 登录,返回 token"""
|
|
||||||
sms = api("POST", "/api/auth/send-sms", {"phone": phone})
|
|
||||||
code = sms.get("data", {}).get("devCode", "")
|
|
||||||
if not code:
|
|
||||||
return None
|
|
||||||
result = api("POST", "/api/auth/login", {"phone": phone, "smsCode": code})
|
|
||||||
return result.get("data", {}).get("accessToken")
|
|
||||||
|
|
||||||
def sse_stream(token, agent_type, message):
|
|
||||||
"""连接 SSE 端点,返回所有事件"""
|
|
||||||
url = f"{BASE}/api/ai/{agent_type}/chat?message={urllib.parse.quote(message)}&token={urllib.parse.quote(token or '')}"
|
|
||||||
req = urllib.request.Request(url)
|
|
||||||
events = []
|
|
||||||
try:
|
|
||||||
resp = urllib.request.urlopen(req, timeout=60)
|
|
||||||
for line_bytes in resp:
|
|
||||||
line = line_bytes.decode("utf-8").strip()
|
|
||||||
if line.startswith("data: "):
|
|
||||||
data = line[6:]
|
|
||||||
if data == "[DONE]":
|
|
||||||
break
|
|
||||||
try:
|
|
||||||
events.append(json.loads(data))
|
|
||||||
except:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
events.append({"error": str(e)})
|
|
||||||
return events
|
|
||||||
|
|
||||||
def section(title):
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" {title}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("1. 认证流程")
|
|
||||||
# ============================================================
|
|
||||||
print(" 1.1 发送验证码...")
|
|
||||||
sms = api("POST", "/api/auth/send-sms", {"phone": "13800000001"})
|
|
||||||
check("发送验证码", sms.get("code") == 0, str(sms.get("message","")))
|
|
||||||
code = sms.get("data", {}).get("devCode", "")
|
|
||||||
|
|
||||||
print(" 1.2 验证码登录...")
|
|
||||||
login_result = api("POST", "/api/auth/login", {"phone": "13800000001", "smsCode": code})
|
|
||||||
check("登录成功", login_result.get("code") == 0)
|
|
||||||
TOKEN = login_result.get("data", {}).get("accessToken", "")
|
|
||||||
REFRESH = login_result.get("data", {}).get("refreshToken", "")
|
|
||||||
check("返回accessToken", len(TOKEN) > 50)
|
|
||||||
check("返回refreshToken", len(REFRESH) > 20)
|
|
||||||
|
|
||||||
print(" 1.3 刷新Token...")
|
|
||||||
refresh_result = api("POST", "/api/auth/refresh", {"refreshToken": REFRESH})
|
|
||||||
check("刷新Token成功", refresh_result.get("code") == 0)
|
|
||||||
check("下发新Token", len(refresh_result.get("data", {}).get("accessToken", "")) > 50)
|
|
||||||
|
|
||||||
print(" 1.4 登出...")
|
|
||||||
logout_result = api("POST", "/api/auth/logout", {"refreshToken": REFRESH})
|
|
||||||
check("登出成功", logout_result.get("code") == 0)
|
|
||||||
|
|
||||||
# 重新登录获取token
|
|
||||||
TOKEN = login()
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("2. 用户与档案")
|
|
||||||
# ============================================================
|
|
||||||
profile = api("GET", "/api/user/profile", token=TOKEN)
|
|
||||||
check("获取个人信息", profile.get("code") == 0)
|
|
||||||
|
|
||||||
archive = api("GET", "/api/user/health-archive", token=TOKEN)
|
|
||||||
check("获取健康档案", archive.get("code") == 0)
|
|
||||||
check("档案有诊断信息", archive.get("data", {}).get("diagnosis") is not None if archive.get("data") else False,
|
|
||||||
"诊断=" + str(archive.get("data", {}).get("diagnosis", "无")))
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("3. 健康数据 CRUD")
|
|
||||||
# ============================================================
|
|
||||||
print(" 3.1 录入血压...")
|
|
||||||
bp = api("POST", "/api/health-records", token=TOKEN, data={
|
|
||||||
"type": "BloodPressure", "systolic": 128, "diastolic": 82,
|
|
||||||
"unit": "mmHg", "source": "Manual"
|
|
||||||
})
|
|
||||||
check("录入血压", bp.get("code") == 0, str(bp.get("message","")))
|
|
||||||
|
|
||||||
print(" 3.2 录入心率...")
|
|
||||||
hr = api("POST", "/api/health-records", token=TOKEN, data={
|
|
||||||
"type": "HeartRate", "value": 72, "unit": "次/分", "source": "Manual"
|
|
||||||
})
|
|
||||||
check("录入心率", hr.get("code") == 0, str(hr.get("message","")))
|
|
||||||
|
|
||||||
print(" 3.3 录入血糖...")
|
|
||||||
glu = api("POST", "/api/health-records", token=TOKEN, data={
|
|
||||||
"type": "Glucose", "value": 5.5, "unit": "mmol/L", "source": "Manual"
|
|
||||||
})
|
|
||||||
check("录入血糖", glu.get("code") == 0, str(glu.get("message","")))
|
|
||||||
|
|
||||||
print(" 3.4 录入血氧...")
|
|
||||||
spo2 = api("POST", "/api/health-records", token=TOKEN, data={
|
|
||||||
"type": "SpO2", "value": 98, "unit": "%", "source": "Manual"
|
|
||||||
})
|
|
||||||
check("录入血氧", spo2.get("code") == 0, str(spo2.get("message","")))
|
|
||||||
|
|
||||||
print(" 3.5 获取最新数据...")
|
|
||||||
latest = api("GET", "/api/health-records/latest", token=TOKEN)
|
|
||||||
check("获取最新数据", latest.get("code") == 0)
|
|
||||||
check("血压存在", latest.get("data", {}).get("BloodPressure") is not None)
|
|
||||||
|
|
||||||
print(" 3.6 获取趋势数据...")
|
|
||||||
trend = api("GET", "/api/health-records/trend?type=HeartRate&period=7", token=TOKEN)
|
|
||||||
check("获取趋势数据", trend.get("code") == 0)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("4. 用药管理")
|
|
||||||
# ============================================================
|
|
||||||
print(" 4.1 获取用药列表...")
|
|
||||||
meds = api("GET", "/api/medications", token=TOKEN)
|
|
||||||
check("获取用药列表", meds.get("code") == 0)
|
|
||||||
|
|
||||||
print(" 4.2 添加用药...")
|
|
||||||
new_med = api("POST", "/api/medications", token=TOKEN, data={
|
|
||||||
"name": "阿司匹林", "dosage": "100mg", "frequency": "Daily",
|
|
||||||
"timeOfDay": ["08:00"], "source": "Manual", "startDate": "2026-06-02"
|
|
||||||
})
|
|
||||||
check("添加用药", new_med.get("code") == 0, str(new_med.get("message","")))
|
|
||||||
med_id = new_med.get("data", {}).get("id", "")
|
|
||||||
|
|
||||||
print(" 4.3 服药打卡...")
|
|
||||||
if med_id:
|
|
||||||
confirm = api("POST", f"/api/medications/{med_id}/confirm", token=TOKEN)
|
|
||||||
check("服药打卡", confirm.get("code") == 0, str(confirm.get("message","")))
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("5. 饮食记录")
|
|
||||||
# ============================================================
|
|
||||||
diet = api("GET", "/api/diet-records?date=2026-06-02", token=TOKEN)
|
|
||||||
check("查询饮食记录", diet.get("code") == 0)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("6. 运动计划")
|
|
||||||
# ============================================================
|
|
||||||
print(" 6.1 获取当前计划...")
|
|
||||||
plan = api("GET", "/api/exercise-plans/current", token=TOKEN)
|
|
||||||
check("获取当前计划", plan.get("code") == 0)
|
|
||||||
|
|
||||||
print(" 6.2 创建运动计划...")
|
|
||||||
new_plan = api("POST", "/api/exercise-plans", token=TOKEN, data={
|
|
||||||
"weekStartDate": "2026-06-02",
|
|
||||||
"items": [
|
|
||||||
{"dayOfWeek": 1, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False},
|
|
||||||
{"dayOfWeek": 3, "exerciseType": "太极", "durationMinutes": 40, "isRestDay": False},
|
|
||||||
{"dayOfWeek": 5, "exerciseType": "散步", "durationMinutes": 30, "isRestDay": False},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
check("创建运动计划", new_plan.get("code") == 0, str(new_plan.get("message","")))
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("7. 医生与问诊")
|
|
||||||
# ============================================================
|
|
||||||
print(" 7.1 医生列表...")
|
|
||||||
docs = api("GET", "/api/doctors", token=TOKEN)
|
|
||||||
check("获取医生列表", docs.get("code") == 0)
|
|
||||||
check("有医生数据", len(docs.get("data", [])) > 0, f"共{len(docs.get('data',[]))}位医生")
|
|
||||||
|
|
||||||
print(" 7.2 问诊配额...")
|
|
||||||
quota = api("GET", "/api/user/consultation-quota", token=TOKEN)
|
|
||||||
check("获取问诊配额", quota.get("code") == 0)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("8. AI 智能体对话")
|
|
||||||
# ============================================================
|
|
||||||
agents_to_test = [
|
|
||||||
("default", "你好,介绍一下你自己"),
|
|
||||||
("health", "我血压128/82"),
|
|
||||||
("medication", "我现在在吃什么药"),
|
|
||||||
("consultation", "最近胸口有点不舒服"),
|
|
||||||
("diet", "我中午吃了红烧肉和米饭"),
|
|
||||||
("exercise", "帮我查询运动计划"),
|
|
||||||
]
|
|
||||||
for agent_name, msg in agents_to_test:
|
|
||||||
print(f" 8.{agents_to_test.index((agent_name,msg))+1} {agent_name} Agent: \"{msg[:30]}...\"")
|
|
||||||
TOKEN = login() # fresh token
|
|
||||||
events = sse_stream(TOKEN, agent_name, msg)
|
|
||||||
|
|
||||||
has_answer = any(e.get("action") == "answer" for e in events)
|
|
||||||
has_tool = any(e.get("action") == "tool_result" for e in events)
|
|
||||||
has_conv_id = any(e.get("action") == "conversation_id" for e in events)
|
|
||||||
errors = [e for e in events if e.get("action") == "error"]
|
|
||||||
|
|
||||||
check(f"{agent_name}: 对话建立", has_conv_id)
|
|
||||||
check(f"{agent_name}: 有回复", has_answer or has_tool,
|
|
||||||
f"(events: {len(events)}, tools: {has_tool}, answer: {has_answer})")
|
|
||||||
check(f"{agent_name}: 无错误", len(errors) == 0,
|
|
||||||
f"errors: {[e.get('message','') for e in errors]}" if errors else "")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("9. 对话历史")
|
|
||||||
# ============================================================
|
|
||||||
convs = api("GET", "/api/ai/conversations", token=TOKEN)
|
|
||||||
check("获取对话列表", convs.get("code") == 0)
|
|
||||||
check("有对话记录", len(convs.get("data", [])) > 0, f"共{len(convs.get('data',[]))}条")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("10. VLM 食物识别")
|
|
||||||
# ============================================================
|
|
||||||
# 尝试上传测试图片
|
|
||||||
test_img = "D:/health_project/食堂三菜一饭热量估算.png"
|
|
||||||
if os.path.exists(test_img):
|
|
||||||
# Use subprocess for multipart upload
|
|
||||||
import subprocess
|
|
||||||
cmd = [
|
|
||||||
'curl', '-s', '--max-time', '30', '-X', 'POST',
|
|
||||||
f'{BASE}/api/ai/analyze-food-image',
|
|
||||||
'-H', f'Authorization: Bearer {TOKEN}',
|
|
||||||
'-F', f'images=@{test_img}'
|
|
||||||
]
|
|
||||||
r = subprocess.run(cmd, capture_output=True, text=True)
|
|
||||||
try:
|
|
||||||
vlm = json.loads(r.stdout)
|
|
||||||
check("VLM食物识别调通", vlm.get("code") == 0, str(vlm.get("message","")))
|
|
||||||
has_data = bool(vlm.get("data", ""))
|
|
||||||
check("VLM返回数据", has_data, f"data长度: {len(str(vlm.get('data','')))}")
|
|
||||||
except:
|
|
||||||
check("VLM食物识别", False, "JSON解析失败")
|
|
||||||
else:
|
|
||||||
check("VLM测试图片存在", False, f"{test_img} 不存在")
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("11. 报告列表")
|
|
||||||
# ============================================================
|
|
||||||
reports = api("GET", "/api/reports", token=TOKEN)
|
|
||||||
check("获取报告列表", reports.get("code") == 0)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
section("12. 通知偏好")
|
|
||||||
# ============================================================
|
|
||||||
notifs = api("GET", "/api/notifications/preferences", token=TOKEN)
|
|
||||||
check("获取通知偏好", notifs.get("code") == 0)
|
|
||||||
|
|
||||||
# ============================================================
|
|
||||||
print(f"\n{'='*60}")
|
|
||||||
print(f" 测试结果: PASS={PASSED} FAIL={FAILED} TOTAL={PASSED+FAILED}")
|
|
||||||
print(f"{'='*60}")
|
|
||||||
|
|
||||||
if FAILED > 0:
|
|
||||||
sys.exit(1)
|
|
||||||
@@ -4,10 +4,13 @@ import '../pages/auth/login_page.dart';
|
|||||||
import '../pages/home/home_page.dart';
|
import '../pages/home/home_page.dart';
|
||||||
import '../pages/chart/trend_page.dart';
|
import '../pages/chart/trend_page.dart';
|
||||||
import '../pages/medication/medication_list_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/report_pages.dart';
|
||||||
|
import '../pages/report/ai_analysis_page.dart';
|
||||||
import '../pages/consultation/consultation_pages.dart';
|
import '../pages/consultation/consultation_pages.dart';
|
||||||
import '../pages/settings/settings_pages.dart';
|
import '../pages/settings/settings_pages.dart';
|
||||||
import '../pages/profile/profile_page.dart';
|
import '../pages/settings/notification_prefs_page.dart';
|
||||||
|
import '../pages/profile/profile_detail_page.dart';
|
||||||
import '../pages/diet/diet_capture_page.dart';
|
import '../pages/diet/diet_capture_page.dart';
|
||||||
import '../pages/remaining_pages.dart';
|
import '../pages/remaining_pages.dart';
|
||||||
|
|
||||||
@@ -28,11 +31,13 @@ Widget buildPage(RouteInfo route) {
|
|||||||
case 'medicationAdd':
|
case 'medicationAdd':
|
||||||
return const MedicationEditPage();
|
return const MedicationEditPage();
|
||||||
case 'medicationEdit':
|
case 'medicationEdit':
|
||||||
return MedicationEditPage(id: params['id']);
|
return const MedicationEditPage();
|
||||||
case 'reports':
|
case 'reports':
|
||||||
return const ReportListPage();
|
return const ReportListPage();
|
||||||
case 'reportDetail':
|
case 'reportDetail':
|
||||||
return ReportDetailPage(id: params['id']!);
|
return ReportDetailPage(id: params['id']!);
|
||||||
|
case 'aiAnalysis':
|
||||||
|
return const AiAnalysisPage();
|
||||||
case 'doctors':
|
case 'doctors':
|
||||||
return const DoctorListPage();
|
return const DoctorListPage();
|
||||||
case 'consultation':
|
case 'consultation':
|
||||||
@@ -44,7 +49,7 @@ Widget buildPage(RouteInfo route) {
|
|||||||
case 'dietCapture':
|
case 'dietCapture':
|
||||||
return const DietCapturePage();
|
return const DietCapturePage();
|
||||||
case 'profile':
|
case 'profile':
|
||||||
return const ProfilePage();
|
return const ProfileDetailPage();
|
||||||
case 'profileEdit':
|
case 'profileEdit':
|
||||||
return const EditProfilePage();
|
return const EditProfilePage();
|
||||||
case 'healthArchive':
|
case 'healthArchive':
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
/// 路由信息
|
/// 路由信息
|
||||||
|
|||||||
@@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 登录页——手机号 + 验证码
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
|
|
||||||
@override
|
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||||
ConsumerState<LoginPage> createState() => _LoginPageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
@@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
bool _loading = false;
|
bool _loading = false;
|
||||||
String? _error;
|
String? _error;
|
||||||
|
|
||||||
@override
|
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
|
||||||
void dispose() {
|
|
||||||
_phoneCtrl.dispose();
|
|
||||||
_codeCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _sendSms() async {
|
Future<void> _sendSms() async {
|
||||||
final phone = _phoneCtrl.text.trim();
|
final phone = _phoneCtrl.text.trim();
|
||||||
if (phone.length != 11 || !phone.startsWith('1')) {
|
if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; }
|
||||||
setState(() => _error = '请输入正确的手机号');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() { _sending = true; _error = null; });
|
setState(() { _sending = true; _error = null; });
|
||||||
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
final result = await ref.read(authProvider.notifier).sendSms(phone);
|
||||||
setState(() { _sending = false; });
|
setState(() { _sending = false; });
|
||||||
if (result.error != null) {
|
if (result.error != null) { setState(() => _error = result.error); return; }
|
||||||
setState(() => _error = result.error);
|
if (result.devCode != null) _codeCtrl.text = result.devCode!;
|
||||||
return;
|
setState(() => _countdown = 60); _startCountdown();
|
||||||
}
|
|
||||||
// 开发阶段自动填充验证码
|
|
||||||
if (result.devCode != null) {
|
|
||||||
_codeCtrl.text = result.devCode!;
|
|
||||||
}
|
|
||||||
setState(() => _countdown = 60);
|
|
||||||
_startCountdown();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _startCountdown() async {
|
void _startCountdown() async {
|
||||||
for (var i = 60; i > 0; i--) {
|
for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _countdown = i - 1); }
|
||||||
await Future.delayed(const Duration(seconds: 1));
|
|
||||||
if (!mounted) return;
|
|
||||||
setState(() => _countdown = i - 1);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _login() async {
|
Future<void> _login() async {
|
||||||
if (!_agreed) {
|
if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; }
|
||||||
setState(() => _error = '请阅读并同意服务协议和隐私政策');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setState(() { _loading = true; _error = null; });
|
setState(() { _loading = true; _error = null; });
|
||||||
final err = await ref.read(authProvider.notifier).login(
|
final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||||
_phoneCtrl.text.trim(),
|
setState(() => _loading = false );
|
||||||
_codeCtrl.text.trim(),
|
if (err != null) { setState(() => _error = err); return; }
|
||||||
);
|
|
||||||
setState(() => _loading = false);
|
|
||||||
if (err != null) {
|
|
||||||
setState(() => _error = err);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
goRoute(ref, 'home');
|
goRoute(ref, 'home');
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final authState = ref.watch(authProvider);
|
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(
|
return Scaffold(
|
||||||
body: SafeArea(
|
body: Container(
|
||||||
child: SingleChildScrollView(
|
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF5F3FF), Color(0xFFEDEBFF), Color(0xFFE8E4FF)])),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
|
||||||
child: Column(
|
const SizedBox(height: 60),
|
||||||
children: [
|
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
|
||||||
const SizedBox(height: 80),
|
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(0xFF635BFF))),
|
||||||
// Logo
|
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))),
|
||||||
Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary),
|
])),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 24),
|
||||||
Text('健康管家', style: Theme.of(context).textTheme.headlineLarge),
|
Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium),
|
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
|
||||||
const SizedBox(height: 48),
|
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(0xFF635BFF), width: 1.5)))),
|
||||||
TextField(
|
const SizedBox(height: 16),
|
||||||
controller: _phoneCtrl,
|
Row(children: [
|
||||||
keyboardType: TextInputType.phone,
|
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
|
||||||
maxLength: 11,
|
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(0xFF635BFF), width: 1.5)), counterText: ''))),
|
||||||
decoration: const InputDecoration(
|
const SizedBox(width: 12),
|
||||||
hintText: '手机号',
|
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(0xFF635BFF), 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)))),
|
||||||
prefixText: '+86 ',
|
]),
|
||||||
counterText: '',
|
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(0xFF635BFF) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF635BFF) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||||
const SizedBox(height: 16),
|
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF)))])),
|
||||||
|
]))),
|
||||||
// 验证码
|
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
|
||||||
Row(
|
const SizedBox(height: 24),
|
||||||
children: [
|
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFF7C73FF), Color(0xFF635BFF)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).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)))),
|
||||||
Expanded(
|
const SizedBox(height: 40),
|
||||||
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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
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),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 引用 AppTheme 颜色
|
|
||||||
class AppColors {
|
|
||||||
static const Color errorRed = Color(0xFFE53935);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../providers/data_providers.dart';
|
|
||||||
|
|
||||||
/// 趋势图表页面
|
|
||||||
class TrendPage extends ConsumerStatefulWidget {
|
class TrendPage extends ConsumerStatefulWidget {
|
||||||
final String metricType;
|
final String metricType;
|
||||||
const TrendPage({super.key, required this.metricType});
|
const TrendPage({super.key, required this.metricType});
|
||||||
@@ -14,52 +12,21 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
|
|
||||||
@override Widget build(BuildContext context) {
|
@override Widget build(BuildContext context) {
|
||||||
final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'};
|
final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'};
|
||||||
final service = ref.watch(healthServiceProvider);
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: Text(labels[widget.metricType] ?? '趋势图表')),
|
backgroundColor: Colors.white,
|
||||||
|
appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: Text(labels[widget.metricType] ?? '趋势图表', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), centerTitle: true),
|
||||||
body: Column(children: [
|
body: Column(children: [
|
||||||
Padding(
|
Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
||||||
padding: const EdgeInsets.all(16),
|
_TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)),
|
||||||
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
|
const SizedBox(width: 12), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)),
|
||||||
_TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)),
|
const SizedBox(width: 12), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)),
|
||||||
const SizedBox(width: 8), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)),
|
])),
|
||||||
const SizedBox(width: 8), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)),
|
Container(margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: const Color(0xFFF8F9FF), borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE8E6FF))), child: Column(children: [
|
||||||
]),
|
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(widget.metricType == 'blood_pressure' ? '血压趋势' : labels[widget.metricType] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), Row(children: [Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF635BFF), shape: BoxShape.circle)), const SizedBox(width: 4), Text('收缩压', style: TextStyle(fontSize: 12, color: Colors.grey[600])), const SizedBox(width: 16), Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF43A047), shape: BoxShape.circle)), const SizedBox(width: 4), Text('舒张压', style: TextStyle(fontSize: 12, color: Colors.grey[600]))])]),
|
||||||
),
|
const SizedBox(height: 24),
|
||||||
Expanded(child: FutureBuilder<List<Map<String, dynamic>>>(
|
SizedBox(height: 200, child: CustomPaint(painter: _LineChartPainter(period: _period), size: Size.infinite)),
|
||||||
future: service.getTrend(widget.metricType, period: _period),
|
])),
|
||||||
builder: (ctx, snap) {
|
if (widget.metricType == 'blood_pressure') Container(margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFEEEEEE))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [const _StatItem(label: '最高', value: '145', unit: '', color: Color(0xFFE53935)), const _StatItem(label: '最低', value: '78', unit: '', color: Color(0xFF43A047)), const _StatItem(label: '平均', value: '120', unit: '/80', color: Color(0xFF635BFF))])),
|
||||||
if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator());
|
|
||||||
if (!snap.hasData || snap.data!.isEmpty) {
|
|
||||||
return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
Icon(Icons.show_chart, size: 64, color: Colors.grey[300]),
|
|
||||||
const SizedBox(height: 12), Text('暂无足够数据', style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
]));
|
|
||||||
}
|
|
||||||
final records = snap.data!;
|
|
||||||
return ListView.builder(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
|
||||||
itemCount: records.length,
|
|
||||||
itemBuilder: (ctx, i) {
|
|
||||||
final r = records[i];
|
|
||||||
String value;
|
|
||||||
if (widget.metricType == 'blood_pressure') {
|
|
||||||
value = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg';
|
|
||||||
} else {
|
|
||||||
value = '${r['value'] ?? '--'}';
|
|
||||||
}
|
|
||||||
final isAbnormal = r['isAbnormal'] == true;
|
|
||||||
final date = r['recordedAt'] != null ? DateTime.parse(r['recordedAt']).toLocal().toString().substring(0, 16) : '--';
|
|
||||||
return ListTile(
|
|
||||||
title: Text(value, style: TextStyle(fontSize: 16, color: isAbnormal ? const Color(0xFFE53935) : null)),
|
|
||||||
subtitle: Text(date, style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
|
||||||
trailing: isAbnormal ? const Icon(Icons.warning_amber, color: Color(0xFFE53935), size: 20) : const Icon(Icons.check_circle, color: Color(0xFF43A047), size: 20),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
)),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -68,12 +35,53 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
class _TimeChip extends StatelessWidget {
|
class _TimeChip extends StatelessWidget {
|
||||||
final String label; final bool selected; final VoidCallback onTap;
|
final String label; final bool selected; final VoidCallback onTap;
|
||||||
const _TimeChip({required this.label, required this.selected, required this.onTap});
|
const _TimeChip({required this.label, required this.selected, required this.onTap});
|
||||||
|
|
||||||
@override Widget build(BuildContext context) => GestureDetector(
|
@override Widget build(BuildContext context) => GestureDetector(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
child: Container(
|
child: Container(padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0))), child: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? Colors.white : const Color(0xFF757575)))),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
|
||||||
decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFF635BFF))),
|
|
||||||
child: Text(label, style: TextStyle(fontSize: 14, color: selected ? Colors.white : const Color(0xFF635BFF))),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _StatItem extends StatelessWidget { final String label; final String value; final String unit; final Color color;
|
||||||
|
const _StatItem({required this.label, required this.value, required this.unit, required this.color});
|
||||||
|
@override Widget build(BuildContext context) => Column(children: [Text(value + unit, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500]))]);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LineChartPainter extends CustomPainter {
|
||||||
|
final int period;
|
||||||
|
_LineChartPainter({required this.period});
|
||||||
|
|
||||||
|
@override void paint(Canvas canvas, Size size) {
|
||||||
|
final paint = Paint()..color = const Color(0xFF635BFF)..strokeWidth = 2..style = PaintingStyle.stroke;
|
||||||
|
final paint2 = Paint()..color = const Color(0xFF43A047)..strokeWidth = 2..style = PaintingStyle.stroke;
|
||||||
|
final fillPaint1 = Paint()..color = const Color(0xFF635BFF)..style = PaintingStyle.fill;
|
||||||
|
final fillPaint2 = Paint()..color = const Color(0xFF43A047)..style = PaintingStyle.fill;
|
||||||
|
final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill;
|
||||||
|
|
||||||
|
final points1 = <Offset>[];
|
||||||
|
final points2 = <Offset>[];
|
||||||
|
|
||||||
|
if (period <= 1) return;
|
||||||
|
|
||||||
|
for (int i = 0; i < period; i++) {
|
||||||
|
final x = size.width * i / (period - 1);
|
||||||
|
points1.add(Offset(x, size.height * 0.3 + (i % 3) * 15));
|
||||||
|
points2.add(Offset(x, size.height * 0.6 + (i % 4) * 10));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (points1.length > 1) {
|
||||||
|
final path1 = Path()..moveTo(points1[0].dx, points1[0].dy);
|
||||||
|
for (var p in points1.skip(1)) path1.lineTo(p.dx, p.dy);
|
||||||
|
canvas.drawPath(path1, paint);
|
||||||
|
|
||||||
|
final path2 = Path()..moveTo(points2[0].dx, points2[0].dy);
|
||||||
|
for (var p in points2.skip(1)) path2.lineTo(p.dx, p.dy);
|
||||||
|
canvas.drawPath(path2, paint2);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var p in points1) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint1); }
|
||||||
|
for (var p in points2) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint2); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@override bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _LineChartPainter || oldDelegate.period != period;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'dart:io';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
|
import '../../core/navigation_provider.dart';
|
||||||
|
|
||||||
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
final dietProvider = NotifierProvider<DietNotifier, DietState>(DietNotifier.new);
|
||||||
|
|
||||||
@@ -432,7 +433,7 @@ class DietCapturePage extends ConsumerWidget {
|
|||||||
content: Text('饮食记录已保存 ✅'),
|
content: Text('饮食记录已保存 ✅'),
|
||||||
backgroundColor: Color(0xFF635BFF),
|
backgroundColor: Color(0xFF635BFF),
|
||||||
));
|
));
|
||||||
Navigator.pop(context);
|
popRoute(ref);
|
||||||
},
|
},
|
||||||
child: const Text('保存记录'),
|
child: const Text('保存记录'),
|
||||||
style: ElevatedButton.styleFrom(
|
style: ElevatedButton.styleFrom(
|
||||||
|
|||||||
@@ -2,48 +2,27 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import 'package:image_picker/image_picker.dart';
|
import 'package:image_picker/image_picker.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import '../../core/api_client.dart';
|
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/chat_provider.dart';
|
import '../../providers/chat_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
import '../../widgets/agent_bar.dart';
|
|
||||||
import '../../widgets/health_drawer.dart';
|
import '../../widgets/health_drawer.dart';
|
||||||
import 'widgets/chat_messages_view.dart';
|
import 'widgets/chat_messages_view.dart';
|
||||||
|
|
||||||
/// 首页——主界面
|
|
||||||
class HomePage extends ConsumerStatefulWidget {
|
class HomePage extends ConsumerStatefulWidget {
|
||||||
const HomePage({super.key});
|
const HomePage({super.key});
|
||||||
@override
|
@override ConsumerState<HomePage> createState() => _HomePageState();
|
||||||
ConsumerState<HomePage> createState() => _HomePageState();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
class _HomePageState extends ConsumerState<HomePage> {
|
class _HomePageState extends ConsumerState<HomePage> {
|
||||||
final _textCtrl = TextEditingController();
|
final _textCtrl = TextEditingController();
|
||||||
final _scrollCtrl = ScrollController();
|
final _scrollCtrl = ScrollController();
|
||||||
bool _taskCardsExpanded = true;
|
bool _taskCardsExpanded = true;
|
||||||
bool _showExpandButton = false;
|
|
||||||
|
|
||||||
@override
|
@override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); }
|
||||||
void initState() {
|
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
|
||||||
super.initState();
|
|
||||||
_scrollCtrl.addListener(_onScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
void _onScroll() {}
|
||||||
void dispose() {
|
|
||||||
_textCtrl.dispose();
|
|
||||||
_scrollCtrl.dispose();
|
|
||||||
super.dispose();
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onScroll() {
|
|
||||||
if (_scrollCtrl.offset > 50 && !_showExpandButton) {
|
|
||||||
setState(() => _showExpandButton = true);
|
|
||||||
} else if (_scrollCtrl.offset <= 50 && _showExpandButton) {
|
|
||||||
setState(() => _showExpandButton = false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _sendMessage() {
|
void _sendMessage() {
|
||||||
final text = _textCtrl.text.trim();
|
final text = _textCtrl.text.trim();
|
||||||
@@ -52,272 +31,150 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
ref.read(chatProvider.notifier).sendMessage(text);
|
ref.read(chatProvider.notifier).sendMessage(text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context) {
|
||||||
Widget build(BuildContext context) {
|
|
||||||
final chatState = ref.watch(chatProvider);
|
final chatState = ref.watch(chatProvider);
|
||||||
|
final auth = ref.watch(authProvider);
|
||||||
|
final user = auth.user;
|
||||||
final selectedAgent = ref.watch(selectedAgentProvider);
|
final selectedAgent = ref.watch(selectedAgentProvider);
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
drawer: const HealthDrawer(),
|
drawer: const HealthDrawer(),
|
||||||
|
backgroundColor: const Color(0xFFF8F7FF),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: Stack(children: [
|
bottom: false,
|
||||||
Column(children: [
|
child: Column(children: [
|
||||||
_buildHeader(context),
|
// ── 顶部栏 ──
|
||||||
if (_taskCardsExpanded) _buildTaskCards(),
|
_buildHeader(user),
|
||||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
|
||||||
_buildAgentPanel(context, selectedAgent),
|
// ── 今日任务(可折叠) ──
|
||||||
const AgentBar(),
|
_buildTaskCardsArea(),
|
||||||
_buildInputBar(),
|
|
||||||
]),
|
// ── 聊天区域(弹性填充剩余空间) ──
|
||||||
_buildExpandButton(),
|
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||||
|
|
||||||
|
// ── 智能体选择器(常驻显示) ──
|
||||||
|
_buildAgentBar(selectedAgent),
|
||||||
|
|
||||||
|
// ── 选中智能体的操作面板 ──
|
||||||
|
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
|
||||||
|
|
||||||
|
// ── 输入框 ──
|
||||||
|
_buildInputBar(context),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildExpandButton() {
|
// ═════════════════════ 顶部栏 ═════════════════════
|
||||||
if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Positioned(
|
Widget _buildHeader(dynamic user) {
|
||||||
top: 60,
|
return Container(
|
||||||
right: 16,
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
child: AnimatedOpacity(
|
|
||||||
opacity: _showExpandButton ? 1.0 : 0.0,
|
|
||||||
duration: const Duration(milliseconds: 300),
|
|
||||||
child: FloatingActionButton(
|
|
||||||
onPressed: () => setState(() => _taskCardsExpanded = true),
|
|
||||||
mini: true,
|
|
||||||
backgroundColor: const Color(0xFF635BFF),
|
|
||||||
child: const Icon(Icons.keyboard_arrow_down, size: 20),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildHeader(BuildContext context) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Builder(builder: (ctx) => IconButton(
|
Builder(builder: (ctx) => GestureDetector(
|
||||||
icon: const Icon(Icons.menu, size: 24),
|
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF635BFF)) : null),
|
||||||
)),
|
)),
|
||||||
const Spacer(),
|
const SizedBox(width: 10),
|
||||||
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
|
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
const Spacer(),
|
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF635BFF)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
|
||||||
const SizedBox(width: 48),
|
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() {
|
|
||||||
final latestHealth = ref.watch(latestHealthProvider);
|
|
||||||
|
|
||||||
return latestHealth.when(
|
|
||||||
data: (data) {
|
|
||||||
final tasks = _getTaskCards(data);
|
|
||||||
if (tasks.isEmpty) return const SizedBox.shrink();
|
|
||||||
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFEFEFF),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
|
||||||
),
|
|
||||||
child: Column(children: [
|
|
||||||
Row(children: [
|
|
||||||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
|
||||||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Column(children: tasks),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () => const SizedBox.shrink(),
|
|
||||||
error: (_, __) {
|
|
||||||
final tasks = _getTaskCards(const {});
|
|
||||||
if (tasks.isEmpty) return const SizedBox.shrink();
|
|
||||||
return Container(
|
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
|
||||||
padding: const EdgeInsets.all(16),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFFEFEFF),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))],
|
|
||||||
),
|
|
||||||
child: Column(children: [
|
|
||||||
Row(children: [
|
|
||||||
const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
|
||||||
const Spacer(),
|
|
||||||
GestureDetector(
|
|
||||||
onTap: () => setState(() => _taskCardsExpanded = false),
|
|
||||||
child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)),
|
|
||||||
),
|
|
||||||
]),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Column(children: tasks),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
String _getGreeting() {
|
String _getGreeting() {
|
||||||
final hour = DateTime.now().hour;
|
final hour = DateTime.now().hour;
|
||||||
if (hour < 6) return '夜深了';
|
|
||||||
if (hour < 9) return '早上好';
|
if (hour < 9) return '早上好';
|
||||||
if (hour < 12) return '上午好';
|
if (hour < 12) return '上午好';
|
||||||
if (hour < 14) return '中午好';
|
|
||||||
if (hour < 18) return '下午好';
|
if (hour < 18) return '下午好';
|
||||||
if (hour < 22) return '晚上好';
|
return '晚上好';
|
||||||
return '夜深了';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _getTaskCards(Map<String, dynamic> healthData) {
|
// ═════════════════════ 今日任务(可折叠/展开) ═════════════════════
|
||||||
final cards = <Widget>[];
|
|
||||||
|
|
||||||
cards.add(_buildMedicationCard());
|
Widget _buildTaskCardsArea() {
|
||||||
cards.add(_buildExerciseCard());
|
final latestHealth = ref.watch(latestHealthProvider);
|
||||||
cards.add(_buildMeasurementCard());
|
|
||||||
|
|
||||||
final abnormalCards = _buildAbnormalCards(healthData);
|
if (_taskCardsExpanded) {
|
||||||
cards.addAll(abnormalCards);
|
return latestHealth.when(
|
||||||
|
data: (data) => _taskCardContent(data),
|
||||||
final summaryCard = _buildSummaryCard(healthData);
|
loading: () => _taskCardContent({}),
|
||||||
if (summaryCard != null) cards.add(summaryCard);
|
error: (_, __) => _taskCardContent({}),
|
||||||
|
);
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMedicationCard() {
|
|
||||||
return _buildTaskCard(
|
|
||||||
'💊',
|
|
||||||
'计划 8:00 吃 阿司匹林 100mg',
|
|
||||||
Icons.check_circle_outline,
|
|
||||||
() => _handleMedicationCheck(),
|
|
||||||
type: 'medication',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildExerciseCard() {
|
|
||||||
return _buildTaskCard(
|
|
||||||
'🏃',
|
|
||||||
'今日待运动:散步 30 分钟',
|
|
||||||
Icons.check_circle_outline,
|
|
||||||
() => _handleExerciseCheck(),
|
|
||||||
type: 'exercise',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildMeasurementCard() {
|
|
||||||
return _buildTaskCard(
|
|
||||||
'🩺',
|
|
||||||
'今日待测量:血压',
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
() => _textCtrl.text = '血压 ',
|
|
||||||
type: 'measurement',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
|
|
||||||
final cards = <Widget>[];
|
|
||||||
|
|
||||||
final bp = healthData['BloodPressure'];
|
|
||||||
if (bp != null && bp is Map) {
|
|
||||||
final systolic = bp['systolic'];
|
|
||||||
final diastolic = bp['diastolic'];
|
|
||||||
if (systolic != null && systolic >= 140) {
|
|
||||||
cards.add(_buildTaskCard(
|
|
||||||
'⚠️',
|
|
||||||
'昨日血压 ${systolic}/${diastolic ?? '--'},偏高',
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
|
||||||
type: 'warning',
|
|
||||||
highlight: true,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
final hr = healthData['HeartRate'];
|
// 折叠状态:只显示一行可点击的标题栏
|
||||||
if (hr != null && hr is Map) {
|
return GestureDetector(
|
||||||
final value = hr['value'];
|
onTap: () => setState(() => _taskCardsExpanded = true),
|
||||||
if (value != null && (value > 100 || value < 60)) {
|
behavior: HitTestBehavior.opaque,
|
||||||
cards.add(_buildTaskCard(
|
|
||||||
'⚠️',
|
|
||||||
'昨日心率 $value,${value > 100 ? '偏高' : '偏低'}',
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
() => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}),
|
|
||||||
type: 'warning',
|
|
||||||
highlight: true,
|
|
||||||
));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return cards;
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
|
|
||||||
final values = <String>[];
|
|
||||||
|
|
||||||
final bp = healthData['BloodPressure'];
|
|
||||||
if (bp != null && bp is Map) {
|
|
||||||
final sys = bp['systolic'];
|
|
||||||
final dia = bp['diastolic'];
|
|
||||||
if (sys != null && dia != null) values.add('血压 $sys/$dia');
|
|
||||||
}
|
|
||||||
|
|
||||||
final hr = healthData['HeartRate'];
|
|
||||||
if (hr != null && hr is Map && hr['value'] != null) {
|
|
||||||
values.add('心率 ${hr['value']}');
|
|
||||||
}
|
|
||||||
|
|
||||||
final glucose = healthData['Glucose'];
|
|
||||||
if (glucose != null && glucose is Map && glucose['value'] != null) {
|
|
||||||
values.add('血糖 ${glucose['value']}');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.isEmpty) return null;
|
|
||||||
|
|
||||||
return _buildTaskCard(
|
|
||||||
'📊',
|
|
||||||
'今日已记录:${values.join('、')}',
|
|
||||||
Icons.arrow_forward_ios,
|
|
||||||
() => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}),
|
|
||||||
type: 'summary',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
decoration: highlight ? BoxDecoration(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
color: const Color(0xFFFDF2F2),
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
) : null,
|
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
Text(icon, style: const TextStyle(fontSize: 20)),
|
Icon(Icons.assignment_turned_in_outlined, size: 18, color: const Color(0xFF635BFF)),
|
||||||
const SizedBox(width: 10),
|
const SizedBox(width: 8),
|
||||||
Expanded(child: Text(text, style: TextStyle(
|
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
fontSize: 14,
|
const Spacer(),
|
||||||
color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333),
|
Text('点击展开', style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF))),
|
||||||
))),
|
Icon(Icons.keyboard_arrow_right, size: 18, color: const Color(0xFF635BFF)),
|
||||||
GestureDetector(
|
]),
|
||||||
onTap: onTap,
|
),
|
||||||
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
|
);
|
||||||
),
|
}
|
||||||
|
|
||||||
|
Widget _taskCardContent(Map<String, dynamic> healthData) {
|
||||||
|
return Container(
|
||||||
|
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||||
|
padding: const EdgeInsets.all(14),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
Row(children: [
|
||||||
|
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
|
const Spacer(),
|
||||||
|
GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Text('收起', style: TextStyle(fontSize: 12, color: const Color(0xFF999999))),
|
||||||
|
Icon(Icons.keyboard_arrow_up, size: 18, color: const Color(0xFF999999)),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
..._getTodayTasks(healthData),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
|
||||||
|
return [
|
||||||
|
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck),
|
||||||
|
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
|
||||||
|
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '),
|
||||||
|
..._buildAbnormalRows(healthData),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
|
||||||
|
final rows = <Widget>[];
|
||||||
|
final bp = healthData['BloodPressure'];
|
||||||
|
if (bp is Map) { final s = bp['systolic']; if (s is int && s >= 140) rows.add(_taskRow(icon: Icons.warning_amber_rounded, label: '血压 $s/${bp['diastolic'] ?? '--'} 偏高', status: 'warning', onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}))); }
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap}) {
|
||||||
|
final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)};
|
||||||
|
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined};
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(bottom: 10),
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: onTap,
|
||||||
|
child: Row(children: [
|
||||||
|
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))),
|
||||||
|
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
|
||||||
|
Icon(icons[status], size: 18, color: colors[status] ?? Colors.grey),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -325,210 +182,138 @@ class _HomePageState extends ConsumerState<HomePage> {
|
|||||||
|
|
||||||
void _handleMedicationCheck() async {
|
void _handleMedicationCheck() async {
|
||||||
await ref.read(medicationServiceProvider).confirm('');
|
await ref.read(medicationServiceProvider).confirm('');
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
if (!mounted) return;
|
||||||
content: Text('已记录服药 ✅'),
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF)));
|
||||||
backgroundColor: Color(0xFF635BFF),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleExerciseCheck() async {
|
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
|
||||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
|
||||||
content: Text('已完成运动 ✅'),
|
|
||||||
backgroundColor: Color(0xFF635BFF),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
Widget _buildAgentPanel(BuildContext context, ActiveAgent? agent) {
|
static final _agentDefs = [
|
||||||
if (agent == null) return const SizedBox.shrink();
|
(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),
|
||||||
|
];
|
||||||
|
|
||||||
return AnimatedContainer(
|
Widget _buildAgentBar(ActiveAgent? selected) {
|
||||||
duration: const Duration(milliseconds: 300),
|
return Container(
|
||||||
curve: Curves.easeInOut,
|
height: 44,
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||||
decoration: BoxDecoration(
|
child: ListView.separated(
|
||||||
color: const Color(0xFFFEFEFF),
|
scrollDirection: Axis.horizontal,
|
||||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
|
itemCount: _agentDefs.length,
|
||||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))],
|
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||||||
|
itemBuilder: (_, i) {
|
||||||
|
final (agent, label, icon) = _agentDefs[i];
|
||||||
|
final isActive = selected == agent;
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||||
|
notifier.select(isActive ? null : agent);
|
||||||
|
// 切换智能体时清空聊天
|
||||||
|
if (!isActive) ref.read(chatProvider.notifier).setAgent(agent);
|
||||||
|
},
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: isActive ? const Color(0xFF635BFF) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: isActive ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Icon(icon, size: 14, color: isActive ? Colors.white : const Color(0xFF666666)),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(label, style: TextStyle(fontSize: 12, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
||||||
_buildAgentPanelHeader(agent),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
..._getAgentButtons(agent),
|
|
||||||
]),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAgentPanelHeader(ActiveAgent agent) {
|
// ═════════════════════ 智能体操作面板(选中后显示) ═════════════════════
|
||||||
final titles = {
|
|
||||||
ActiveAgent.consultation: '🩺 AI 问诊',
|
|
||||||
ActiveAgent.health: '📊 记数据',
|
|
||||||
ActiveAgent.diet: '📸 拍饮食',
|
|
||||||
ActiveAgent.medication: '💊 药管家',
|
|
||||||
ActiveAgent.report: '📋 看报告',
|
|
||||||
ActiveAgent.exercise: '🏃 运动计划',
|
|
||||||
};
|
|
||||||
final tips = {
|
|
||||||
ActiveAgent.consultation: '或直接对我说你的症状',
|
|
||||||
ActiveAgent.health: '或直接对我说:"血压 135/85"',
|
|
||||||
ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"',
|
|
||||||
ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"',
|
|
||||||
ActiveAgent.report: '或直接上传报告图片',
|
|
||||||
ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"',
|
|
||||||
};
|
|
||||||
|
|
||||||
return Column(children: [
|
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
|
||||||
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
final titles = {ActiveAgent.consultation: 'AI 问诊', ActiveAgent.health: '记数据', ActiveAgent.diet: '拍饮食', ActiveAgent.medication: '药管家', ActiveAgent.report: '看报告', ActiveAgent.exercise: '运动计划'};
|
||||||
const SizedBox(height: 4),
|
final tips = {ActiveAgent.consultation: '或直接对我说你的症状', ActiveAgent.health: '或直接对我说:"血压 135/85"', ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"', ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"', ActiveAgent.report: '或直接上传报告图片', ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"'};
|
||||||
Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
|
||||||
]);
|
return Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||||
|
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||||
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||||
|
Row(children: [
|
||||||
|
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
|
const SizedBox(width: 6),
|
||||||
|
Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
|
||||||
|
GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 18, color: Colors.grey[400])),
|
||||||
|
]),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))),
|
||||||
|
]));
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||||
final buttons = <Widget>[];
|
switch (agent) {
|
||||||
if (agent == ActiveAgent.health) {
|
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)];
|
||||||
buttons.add(_panelBtn('手动录入血压', Icons.favorite));
|
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
|
||||||
buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype));
|
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)];
|
||||||
buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart));
|
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)];
|
||||||
buttons.add(_panelBtn('手动录入血氧', Icons.air));
|
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)];
|
||||||
buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight));
|
default: return [];
|
||||||
} 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) {
|
Widget _agentBtn(String label, IconData icon) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 8),
|
padding: const EdgeInsets.only(right: 8),
|
||||||
child: SizedBox(
|
child: ElevatedButton.icon(
|
||||||
width: double.infinity,
|
onPressed: () => _onAgentAction(label),
|
||||||
child: ElevatedButton.icon(
|
icon: Icon(icon, size: 14),
|
||||||
onPressed: () => _onAgentAction(label),
|
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||||
icon: Icon(icon, size: 18),
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12)),
|
||||||
label: Text(label, style: const TextStyle(fontSize: 14)),
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: const Color(0xFFF5F3FF),
|
|
||||||
foregroundColor: const Color(0xFF635BFF),
|
|
||||||
elevation: 0,
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAgentAction(String label) {
|
void _onAgentAction(String label) {
|
||||||
switch (label) {
|
switch (label) {
|
||||||
case '拍照':
|
case '拍照识别': case '上传照片': pushRoute(ref, 'dietCapture');
|
||||||
pushRoute(ref, 'dietCapture');
|
case '录入血压': _textCtrl.text = '血压 ';
|
||||||
break;
|
case '录入血糖': _textCtrl.text = '血糖 ';
|
||||||
case '上传照片':
|
case '录入心率': _textCtrl.text = '心率 ';
|
||||||
pushRoute(ref, 'dietCapture');
|
case '录入血氧': _textCtrl.text = '血氧 ';
|
||||||
break;
|
case '录入体重': _textCtrl.text = '体重 ';
|
||||||
case '手动录入血压':
|
case '用药管理': pushRoute(ref, 'medications');
|
||||||
_textCtrl.text = '血压 ';
|
case '找医生': pushRoute(ref, 'doctors');
|
||||||
break;
|
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
|
||||||
case '手动录入血糖':
|
|
||||||
_textCtrl.text = '血糖 ';
|
|
||||||
break;
|
|
||||||
case '手动录入心率':
|
|
||||||
_textCtrl.text = '心率 ';
|
|
||||||
break;
|
|
||||||
case '手动录入血氧':
|
|
||||||
_textCtrl.text = '血氧 ';
|
|
||||||
break;
|
|
||||||
case '手动录入体重':
|
|
||||||
_textCtrl.text = '体重 ';
|
|
||||||
break;
|
|
||||||
case '用药管理':
|
|
||||||
pushRoute(ref, 'medications');
|
|
||||||
break;
|
|
||||||
case '找医生':
|
|
||||||
pushRoute(ref, 'doctors');
|
|
||||||
break;
|
|
||||||
case '查看本周计划':
|
|
||||||
pushRoute(ref, 'exercisePlan');
|
|
||||||
break;
|
|
||||||
case '创建新计划':
|
|
||||||
pushRoute(ref, 'exercisePlan');
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _pickImage(ImageSource source) async {
|
Future<void> _pickImage(ImageSource source) async {
|
||||||
final picker = ImagePicker();
|
final picker = ImagePicker();
|
||||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||||
if (picked != null) {
|
if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; _textCtrl.text = '[图片已上传]'; if (mounted) setState(() {}); }
|
||||||
final token = await ref.read(apiClientProvider).accessToken;
|
|
||||||
if (token == null) return;
|
|
||||||
_textCtrl.text = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}';
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _showAttachmentPicker(BuildContext context) {
|
void _showAttachmentPicker(BuildContext context) {
|
||||||
showModalBottomSheet(
|
showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(children: [
|
||||||
context: context,
|
ListTile(leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }),
|
||||||
builder: (ctx) => SafeArea(
|
ListTile(leading: const Icon(Icons.photo_library), title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }),
|
||||||
child: Wrap(
|
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(() {}); }}),
|
||||||
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}';
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildInputBar() {
|
Widget _buildInputBar(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||||
color: Colors.white,
|
|
||||||
border: Border(top: BorderSide(color: Colors.grey.shade200)),
|
|
||||||
),
|
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
IconButton(icon: const Icon(Icons.attach_file, size: 22, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||||
Expanded(
|
Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: InputBorder.none, isDense: true), onSubmitted: (_) => _sendMessage())),
|
||||||
child: TextField(
|
IconButton(icon: const Icon(Icons.send, size: 22, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
||||||
controller: _textCtrl,
|
|
||||||
decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
|
|
||||||
onSubmitted: (_) => _sendMessage(),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
97
health_app/lib/pages/medication/medication_edit_page.dart
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
|
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||||
|
final String? medicationId;
|
||||||
|
const MedicationEditPage({super.key, this.medicationId});
|
||||||
|
|
||||||
|
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
|
final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片');
|
||||||
|
final _dosageCtrl = TextEditingController(text: '100mg');
|
||||||
|
String _frequency = '每日1次';
|
||||||
|
String _time = '08:00';
|
||||||
|
DateTime _startDate = DateTime.now();
|
||||||
|
String _duration = '长期服用';
|
||||||
|
|
||||||
|
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); super.dispose(); }
|
||||||
|
|
||||||
|
@override Widget build(BuildContext context) {
|
||||||
|
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: const Text('编辑用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功 ✅'), backgroundColor: Color(0xFF635BFF)));
|
||||||
|
Navigator.pop(context);
|
||||||
|
},
|
||||||
|
child: const Text('保存', style: TextStyle(color: Color(0xFF635BFF), fontWeight: FontWeight.w600)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||||
|
const Text('药品信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
TextField(controller: _nameCtrl, decoration: InputDecoration(hintText: '请输入药品名称', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
TextField(controller: _dosageCtrl, decoration: InputDecoration(hintText: '如:100mg', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
const Text('服用设置', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
GestureDetector(onTap: _pickFrequency, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_frequency, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(onTap: _pickTime, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_time, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.access_time, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(onTap: _pickDate, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text('${_startDate.year}-${_startDate.month.toString().padLeft(2, '0')}-${_startDate.day.toString().padLeft(2, '0')}', style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.calendar_today, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
GestureDetector(onTap: _pickDuration, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_duration, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
SizedBox(width: double.infinity, height: 50, child: ElevatedButton(
|
||||||
|
onPressed: () {},
|
||||||
|
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF635BFF), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25))),
|
||||||
|
child: const Text('新增用药', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
|
)),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickFrequency() async {
|
||||||
|
final options = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
|
||||||
|
final selected = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||||
|
);
|
||||||
|
if (selected != null && mounted) setState(() => _frequency = selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickTime() async {
|
||||||
|
final time = await showTimePicker(context: context, initialTime: TimeOfDay.now());
|
||||||
|
if (time != null && mounted) setState(() => _time = time.format(context));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickDate() async {
|
||||||
|
final date = await showDatePicker(context: context, firstDate: DateTime(2020), lastDate: DateTime(2030), initialDate: _startDate);
|
||||||
|
if (date != null && mounted) setState(() => _startDate = date);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pickDuration() async {
|
||||||
|
final options = ['长期服用', '7天', '14天', '30天', '90天'];
|
||||||
|
final selected = await showModalBottomSheet<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||||
|
);
|
||||||
|
if (selected != null && mounted) setState(() => _duration = selected);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,78 +3,160 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
|
||||||
/// 用药列表页
|
|
||||||
class MedicationListPage extends ConsumerWidget {
|
class MedicationListPage extends ConsumerWidget {
|
||||||
const MedicationListPage({super.key});
|
const MedicationListPage({super.key});
|
||||||
|
|
||||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final meds = ref.watch(medicationListProvider);
|
final meds = ref.watch(medicationListProvider);
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('我的用药')),
|
backgroundColor: const Color(0xFFF8F7FF),
|
||||||
body: meds.when(
|
appBar: AppBar(
|
||||||
data: (list) {
|
backgroundColor: Colors.white,
|
||||||
if (list.isEmpty) return _empty(context);
|
elevation: 0,
|
||||||
return ListView.builder(
|
title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||||
itemCount: list.length,
|
centerTitle: true,
|
||||||
itemBuilder: (ctx, i) {
|
actions: [
|
||||||
final m = list[i];
|
TextButton(
|
||||||
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? [];
|
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||||
return Card(
|
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF635BFF)),
|
||||||
child: ListTile(
|
const SizedBox(width: 4),
|
||||||
leading: const Icon(Icons.medication, color: Color(0xFF635BFF)),
|
const Text('添加新药', style: TextStyle(color: Color(0xFF635BFF), fontSize: 14)),
|
||||||
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);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
},
|
|
||||||
loading: () => const Center(child: CircularProgressIndicator()),
|
|
||||||
error: (_, _) => _empty(context),
|
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton.extended(
|
body: Column(children: [
|
||||||
onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); },
|
Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [
|
||||||
icon: const Icon(Icons.add), label: const Text('添加药品'),
|
_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(
|
||||||
|
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];
|
||||||
|
return _MedicationCard(data: m);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
|
||||||
|
error: (_, __) => _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(0xFFEDEBFF),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
border: Border.all(color: const Color(0xFF635BFF).withAlpha(50)),
|
||||||
|
),
|
||||||
|
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF635BFF)),
|
||||||
|
),
|
||||||
|
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(0xFF635BFF) : Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
border: Border.all(color: active ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: active ? FontWeight.w600 : FontWeight.normal,
|
||||||
|
color: active ? Colors.white : const Color(0xFF757575),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
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 _MedicationCard extends StatelessWidget {
|
||||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
final Map<String, dynamic> data;
|
||||||
final String? id;
|
const _MedicationCard({required this.data});
|
||||||
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 {
|
@override
|
||||||
await ref.read(medicationServiceProvider).create({
|
Widget build(BuildContext context) {
|
||||||
'name': _nameCtrl.text, 'dosage': _dosageCtrl.text,
|
return Container(
|
||||||
'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text],
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10),
|
padding: const EdgeInsets.all(16),
|
||||||
});
|
decoration: BoxDecoration(
|
||||||
popRoute(ref);
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||||
|
),
|
||||||
|
child: Row(children: [
|
||||||
|
Container(
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFF5F3FF),
|
||||||
|
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)),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@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('保存'))),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
68
health_app/lib/pages/profile/profile_detail_page.dart
Normal 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(0xFFF8F7FF),
|
||||||
|
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(0xFF635BFF), side: const BorderSide(color: Color(0xFF635BFF)), 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(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.person, size: 40, color: Color(0xFF635BFF))), 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(0xFF635BFF).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(0xFF635BFF)))), error: (_, __) => _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(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))), 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(0xFF635BFF).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(0xFF635BFF)))])), ...items.map((item) => _historyItem(item)).toList()]));
|
||||||
|
}
|
||||||
|
|
||||||
|
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))))]));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,67 +3,98 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 个人中心页面
|
|
||||||
class ProfilePage extends ConsumerWidget {
|
class ProfilePage extends ConsumerWidget {
|
||||||
const ProfilePage({super.key});
|
const ProfilePage({super.key});
|
||||||
|
|
||||||
@override
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
|
||||||
final auth = ref.watch(authProvider);
|
final auth = ref.watch(authProvider);
|
||||||
final user = auth.user;
|
final user = auth.user;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: AppBar(title: const Text('个人中心')),
|
backgroundColor: const Color(0xFFF8F7FF),
|
||||||
body: ListView(
|
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
|
||||||
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])]),]),
|
||||||
Container(
|
const SizedBox(height: 20),
|
||||||
padding: const EdgeInsets.all(24),
|
Row(children: [
|
||||||
color: const Color(0xFFEDEBFF),
|
GestureDetector(
|
||||||
child: Column(
|
onTap: () => pushRoute(ref, 'profileEdit'),
|
||||||
children: [
|
child: Stack(children: [
|
||||||
CircleAvatar(
|
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF635BFF)) : null),
|
||||||
radius: 40,
|
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
|
||||||
backgroundColor: const Color(0xFF635BFF),
|
]),
|
||||||
child: Text(
|
|
||||||
(user?.name ?? '?')[0],
|
|
||||||
style: const TextStyle(fontSize: 32, color: Colors.white),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
|
||||||
Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge),
|
|
||||||
const SizedBox(height: 4),
|
|
||||||
Text(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
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('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, 'profile')),
|
||||||
|
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
|
||||||
|
_MenuItem(icon: Icons.devices, title: '设备管理'),
|
||||||
|
_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('确定')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
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)),
|
||||||
),
|
),
|
||||||
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'); }
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _MenuItem extends StatelessWidget {
|
class _MenuItem extends StatelessWidget {
|
||||||
final IconData icon; final String title; final VoidCallback onTap; final Color? textColor;
|
final IconData icon;
|
||||||
const _MenuItem({required this.icon, required this.title, required this.onTap, this.textColor});
|
final String title;
|
||||||
@override
|
final String? trailing;
|
||||||
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 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(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))),
|
||||||
|
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]),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
41
health_app/lib/pages/report/ai_analysis_page.dart
Normal 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(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF635BFF)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF), 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(0xFFF5F3FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFE8E6FF), 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)).toList()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(0xFFF5F3FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 6), const 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(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF635BFF))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: const Color(0xFF444444)))]));
|
||||||
|
|
||||||
|
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF635BFF))), 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(0xFFF5F3FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF)))), 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)))]))).toList()]);
|
||||||
|
}
|
||||||
216
health_app/lib/pages/settings/notification_prefs_page.dart
Normal 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(0xFFF8F7FF),
|
||||||
|
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(0xFF635BFF),
|
||||||
|
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(0xFF635BFF), 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(0xFF635BFF)))]),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,65 +3,77 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
/// 设置页
|
|
||||||
class SettingsPage extends ConsumerWidget {
|
class SettingsPage extends ConsumerWidget {
|
||||||
const SettingsPage({super.key});
|
const SettingsPage({super.key});
|
||||||
@override
|
|
||||||
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
|
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||||
appBar: AppBar(title: const Text('设置')),
|
return Scaffold(
|
||||||
body: ListView(children: [
|
backgroundColor: const Color(0xFFF8F7FF),
|
||||||
_SetItem(icon: Icons.shield, title: '隐私保护中心', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
|
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [
|
||||||
_SetItem(icon: Icons.notifications, title: '通知偏好', onTap: () => pushRoute(ref, 'notificationPrefs')),
|
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: 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])]),])),
|
||||||
_SetItem(icon: Icons.text_fields, title: '字体大小', trailing: _FontSlider()),
|
const SizedBox(height: 12),
|
||||||
_SetItem(icon: Icons.article, title: '协议与公告', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'terms'})),
|
_SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
|
||||||
_SetItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
|
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L'),
|
||||||
const Divider(),
|
_SetItem(icon: Icons.data_usage_outlined, title: '数据导出'),
|
||||||
_SetItem(icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), onTap: () async {
|
_SetItem(icon: Icons.text_fields_outlined, title: '字体大小', trailingText: 'v1.0.0'),
|
||||||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
_SetItem(icon: Icons.cleaning_services_outlined, title: '清除缓存', subtitle: '73.2 MB'),
|
||||||
title: const Text('退出登录'), content: const Text('确定退出?'),
|
_SetItem(icon: Icons.info_outline, title: '关于健康管家'),
|
||||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
|
_SetItem(icon: Icons.shield_outlined, title: '隐私协议'),
|
||||||
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
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 {
|
class _SetItem extends StatelessWidget {
|
||||||
final IconData icon; final String title; final VoidCallback? onTap; final Widget? trailing; final Color? textColor;
|
final IconData icon;
|
||||||
const _SetItem({required this.icon, required this.title, this.onTap, this.trailing, this.textColor});
|
final String title;
|
||||||
@override
|
final String? subtitle;
|
||||||
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 String? trailingText;
|
||||||
}
|
final VoidCallback? onTap;
|
||||||
|
|
||||||
class _FontSlider extends StatefulWidget {
|
const _SetItem({required this.icon, required this.title, this.subtitle, this.trailingText, this.onTap});
|
||||||
@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)));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// 通知偏好页
|
@override Widget build(BuildContext context) {
|
||||||
class NotificationPrefsPage extends ConsumerWidget {
|
return GestureDetector(
|
||||||
const NotificationPrefsPage({super.key});
|
onTap: onTap,
|
||||||
@override
|
behavior: HitTestBehavior.opaque,
|
||||||
Widget build(BuildContext context, WidgetRef ref) => Scaffold(
|
child: Container(
|
||||||
appBar: AppBar(title: const Text('通知偏好')),
|
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2),
|
||||||
body: ListView(children: [
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||||
_SwitchTile(icon: Icons.medication, title: '用药提醒'),
|
decoration: BoxDecoration(color: Colors.white),
|
||||||
_SwitchTile(icon: Icons.calendar_month, title: '复查提醒'),
|
child: Row(children: [
|
||||||
_SwitchTile(icon: Icons.chat, title: '医生回复'),
|
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))),
|
||||||
_SwitchTile(icon: Icons.warning_amber, title: '异常警告'),
|
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));
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
|
|
||||||
return Drawer(
|
return Drawer(
|
||||||
child: SafeArea(
|
child: SafeArea(
|
||||||
child: Column(
|
child: ListView(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
padding: EdgeInsets.zero,
|
||||||
children: [
|
children: [
|
||||||
// 用户信息
|
// 用户信息
|
||||||
Container(
|
Container(
|
||||||
@@ -75,22 +75,22 @@ class HealthDrawer extends ConsumerWidget {
|
|||||||
TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))),
|
TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
Expanded(
|
conversations.when(
|
||||||
child: conversations.when(
|
|
||||||
data: (items) {
|
data: (items) {
|
||||||
if (items.isEmpty) {
|
if (items.isEmpty) {
|
||||||
return const Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14)));
|
return const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 13))));
|
||||||
}
|
}
|
||||||
return ListView.builder(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
itemCount: items.length,
|
child: Column(
|
||||||
itemBuilder: (ctx, i) => _ConversationItem(item: items[i], ref: ref),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: items.map((item) => _ConversationItem(item: item, ref: ref)).toList(),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||||
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))),
|
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
|
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
|
||||||
@@ -147,30 +147,31 @@ class _ConversationItem extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8F7FF),
|
color: const Color(0xFFF8F7FF),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: ListTile(
|
child: ListTile(
|
||||||
leading: Container(
|
leading: Container(
|
||||||
width: 40,
|
width: 36,
|
||||||
height: 40,
|
height: 36,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEDEBFF),
|
color: const Color(0xFFEDEBFF),
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(8),
|
||||||
),
|
),
|
||||||
child: Icon(_getAgentIcon(item.agent), size: 18, color: const Color(0xFF635BFF)),
|
child: Icon(_getAgentIcon(item.agent), size: 16, color: const Color(0xFF635BFF)),
|
||||||
),
|
),
|
||||||
title: Text(item.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)),
|
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||||
subtitle: Text(item.lastMessage, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||||||
trailing: Column(
|
trailing: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 10, color: Color(0xFFCCCCCC))),
|
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 2),
|
||||||
PopupMenuButton<int>(
|
PopupMenuButton<int>(
|
||||||
icon: const Icon(Icons.more_vert, size: 16, color: Color(0xFFCCCCCC)),
|
icon: const Icon(Icons.more_vert, size: 14, color: Color(0xFFCCCCCC)),
|
||||||
itemBuilder: (_) => [
|
itemBuilder: (_) => [
|
||||||
const PopupMenuItem(value: 1, child: Text('继续聊')),
|
const PopupMenuItem(value: 1, child: Text('继续聊')),
|
||||||
const PopupMenuItem(value: 2, child: Text('删除')),
|
const PopupMenuItem(value: 2, child: Text('删除')),
|
||||||
|
|||||||
BIN
微信图片_20260603102503_4528_320.jpg
Normal file
|
After Width: | Height: | Size: 602 KiB |