feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整
- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用 - 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方 - 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧 - 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影 - 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整 - 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
This commit is contained in:
@@ -48,6 +48,7 @@ public interface IAiConversationRepository
|
|||||||
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct);
|
||||||
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
Task AddConversationAsync(Conversation conversation, CancellationToken ct);
|
||||||
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
Task AddMessageAsync(ConversationMessage message, CancellationToken ct);
|
||||||
|
Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct);
|
||||||
void Delete(Conversation conversation);
|
void Delete(Conversation conversation);
|
||||||
Task SaveChangesAsync(CancellationToken ct);
|
Task SaveChangesAsync(CancellationToken ct);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation
|
|||||||
|
|
||||||
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
public async Task<IReadOnlyList<AiConversationDto>> ListAsync(Guid userId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
await _conversations.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
ct);
|
||||||
var conversations = await _conversations.ListAsync(userId, ct);
|
var conversations = await _conversations.ListAsync(userId, ct);
|
||||||
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList();
|
||||||
}
|
}
|
||||||
|
|||||||
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
35
backend/src/Health.Application/AI/DietCommentaryPolicy.cs
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Application.AI;
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFood(string Name, string Portion, int Calories);
|
||||||
|
|
||||||
|
public static class DietCommentaryPolicy
|
||||||
|
{
|
||||||
|
public const string LegacyPromptPrefix = "饮食记录页需要展示本餐建议。";
|
||||||
|
|
||||||
|
public const string SystemPrompt = """
|
||||||
|
你是健康管理 App 中的饮食建议助手。根据用户本餐食物和健康档案,直接输出2到3条简短建议。
|
||||||
|
每条建议12到22个汉字,不要问候,不要说“好的”“当然”“建议如下”,不要编号,不要使用 Markdown。
|
||||||
|
优先围绕控盐控油、蔬菜和蛋白质搭配、血糖血脂风险及份量控制。
|
||||||
|
不作诊断,不提供治疗、处方、停药或调药结论。语言简洁、专业、面向普通用户。
|
||||||
|
""";
|
||||||
|
|
||||||
|
public static string BuildFoodDescription(IEnumerable<DietCommentaryFood> foods) =>
|
||||||
|
string.Join("、", foods.Select(food =>
|
||||||
|
$"{Normalize(food.Name, 40)}({Normalize(food.Portion, 30)},{Math.Clamp(food.Calories, 0, 10000)}千卡)"));
|
||||||
|
|
||||||
|
public static bool IsLegacyArtifact(IEnumerable<ConversationMessage> messages)
|
||||||
|
{
|
||||||
|
var userMessages = messages.Where(message => message.Role == MessageRole.User).ToList();
|
||||||
|
return userMessages.Count > 0 &&
|
||||||
|
userMessages.All(message => message.Content.TrimStart().StartsWith(LegacyPromptPrefix, StringComparison.Ordinal));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Normalize(string value, int maxLength)
|
||||||
|
{
|
||||||
|
var normalized = string.Join(' ', value.Trim().Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries));
|
||||||
|
return normalized.Length > maxLength ? normalized[..maxLength] : normalized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ public static class HealthRecordRules
|
|||||||
{
|
{
|
||||||
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59,
|
||||||
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
HealthMetricType.HeartRate => request.Value > 100 || request.Value < 60,
|
||||||
HealthMetricType.Glucose => request.Value >= 7.0m || request.Value <= 3.8m,
|
HealthMetricType.Glucose => request.Value > 6.1m || request.Value < 3.9m,
|
||||||
HealthMetricType.SpO2 => request.Value <= 94,
|
HealthMetricType.SpO2 => request.Value <= 94,
|
||||||
HealthMetricType.Weight => false,
|
HealthMetricType.Weight => false,
|
||||||
_ => false
|
_ => false
|
||||||
@@ -58,4 +58,3 @@ public static class HealthRecordRules
|
|||||||
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -101,6 +101,7 @@ public sealed class HealthRecordService(
|
|||||||
latest.Diastolic,
|
latest.Diastolic,
|
||||||
latest.Value,
|
latest.Value,
|
||||||
latest.Unit,
|
latest.Unit,
|
||||||
|
latest.IsAbnormal,
|
||||||
latest.RecordedAt
|
latest.RecordedAt
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -140,7 +141,7 @@ public sealed class HealthRecordService(
|
|||||||
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"),
|
||||||
HealthMetricType.HeartRate =>
|
HealthMetricType.HeartRate =>
|
||||||
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
("心率偏低提醒", $"本次心率为 {record.Value:0.#} 次/分,低于参考范围。如伴明显不适,请及时就医。", "warning", "heart_rate"),
|
||||||
HealthMetricType.Glucose when record.Value >= 7.0m =>
|
HealthMetricType.Glucose when record.Value > 6.1m =>
|
||||||
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
("血糖偏高提醒", $"本次血糖为 {record.Value:0.#} mmol/L,高于参考范围。请结合测量时段并按计划复测。", "warning", "glucose"),
|
||||||
HealthMetricType.Glucose =>
|
HealthMetricType.Glucose =>
|
||||||
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"),
|
||||||
|
|||||||
@@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio
|
|||||||
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) =>
|
||||||
await _db.ConversationMessages.AddAsync(message, ct);
|
await _db.ConversationMessages.AddAsync(message, ct);
|
||||||
|
|
||||||
|
public async Task<int> DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
Guid userId,
|
||||||
|
string promptPrefix,
|
||||||
|
CancellationToken ct)
|
||||||
|
{
|
||||||
|
var candidates = await _db.Conversations
|
||||||
|
.Include(conversation => conversation.Messages)
|
||||||
|
.Where(conversation =>
|
||||||
|
conversation.UserId == userId &&
|
||||||
|
conversation.Messages.Any(message =>
|
||||||
|
message.Role == MessageRole.User &&
|
||||||
|
message.Content.StartsWith(promptPrefix)))
|
||||||
|
.ToListAsync(ct);
|
||||||
|
var artifacts = candidates
|
||||||
|
.Where(conversation => DietCommentaryPolicy.IsLegacyArtifact(conversation.Messages))
|
||||||
|
.ToList();
|
||||||
|
if (artifacts.Count == 0) return 0;
|
||||||
|
|
||||||
|
_db.Conversations.RemoveRange(artifacts);
|
||||||
|
await _db.SaveChangesAsync(ct);
|
||||||
|
return artifacts.Count;
|
||||||
|
}
|
||||||
|
|
||||||
public void Delete(Conversation conversation) =>
|
public void Delete(Conversation conversation) =>
|
||||||
_db.Conversations.Remove(conversation);
|
_db.Conversations.Remove(conversation);
|
||||||
|
|
||||||
|
|||||||
@@ -335,6 +335,49 @@ public static class AiChatEndpoints
|
|||||||
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
return Results.Ok(new { code = 0, data = new { deleted = count }, message = (string?)null });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 饮食分析页专用建议:不创建会话,不保存提示词或回复到历史记录。
|
||||||
|
app.MapPost("/api/ai/diet-commentary", async (
|
||||||
|
DietCommentaryRequest request,
|
||||||
|
HttpContext http,
|
||||||
|
DeepSeekClient llmClient,
|
||||||
|
IPatientContextService patientContexts,
|
||||||
|
CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == null)
|
||||||
|
return Results.Json(new { code = 40002, data = (object?)null, message = "未登录" }, statusCode: 401);
|
||||||
|
|
||||||
|
var foods = request.Foods?
|
||||||
|
.Where(food => !string.IsNullOrWhiteSpace(food.Name) &&
|
||||||
|
!string.IsNullOrWhiteSpace(food.Portion) &&
|
||||||
|
food.Calories > 0)
|
||||||
|
.Take(20)
|
||||||
|
.Select(food => new DietCommentaryFood(food.Name, food.Portion, food.Calories))
|
||||||
|
.ToList() ?? [];
|
||||||
|
if (foods.Count == 0)
|
||||||
|
return Results.Ok(new { code = 40001, data = (object?)null, message = "请先完善食物信息" });
|
||||||
|
|
||||||
|
var patientContext = await patientContexts.BuildAsync(userId.Value, ct);
|
||||||
|
var foodDescription = DietCommentaryPolicy.BuildFoodDescription(foods);
|
||||||
|
var response = await llmClient.ChatAsync(
|
||||||
|
[
|
||||||
|
new ChatMessage
|
||||||
|
{
|
||||||
|
Role = "system",
|
||||||
|
Content = DietCommentaryPolicy.SystemPrompt + "\n\n当前用户健康档案:\n" + patientContext,
|
||||||
|
},
|
||||||
|
new ChatMessage { Role = "user", Content = "本餐食物:" + foodDescription },
|
||||||
|
],
|
||||||
|
maxTokens: 300,
|
||||||
|
temperature: 0.3f,
|
||||||
|
ct: ct);
|
||||||
|
var commentary = response.Choices?.FirstOrDefault()?.Message?.Content?.Trim();
|
||||||
|
if (string.IsNullOrWhiteSpace(commentary))
|
||||||
|
return Results.Ok(new { code = 50001, data = (object?)null, message = "饮食建议生成失败" });
|
||||||
|
|
||||||
|
return Results.Ok(new { code = 0, data = new { commentary }, message = (string?)null });
|
||||||
|
}).RequireAuthorization();
|
||||||
|
|
||||||
app.MapPost("/api/ai/analyze-food-image", async (
|
app.MapPost("/api/ai/analyze-food-image", async (
|
||||||
HttpRequest httpRequest,
|
HttpRequest httpRequest,
|
||||||
HttpContext http,
|
HttpContext http,
|
||||||
@@ -623,7 +666,7 @@ public static class AiChatEndpoints
|
|||||||
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60);
|
||||||
break;
|
break;
|
||||||
case "glucose":
|
case "glucose":
|
||||||
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v >= 7.0m || v <= 3.8m);
|
AddMetricPreview(preview, HealthMetricType.Glucose, GetDecimal(args, "glucose"), "mmol/L", v => v > 6.1m || v < 3.9m);
|
||||||
break;
|
break;
|
||||||
case "spo2":
|
case "spo2":
|
||||||
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
AddMetricPreview(preview, HealthMetricType.SpO2, GetDecimal(args, "spo2"), "%", v => v <= 94);
|
||||||
@@ -818,3 +861,6 @@ public static class AiChatEndpoints
|
|||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record DietCommentaryFoodRequest(string Name, string Portion, int Calories);
|
||||||
|
public sealed record DietCommentaryRequest(IReadOnlyList<DietCommentaryFoodRequest>? Foods);
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ public sealed class ApplicationServiceTests
|
|||||||
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
public Task<IReadOnlyList<ConversationMessage>> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult<IReadOnlyList<ConversationMessage>>([]);
|
||||||
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask;
|
||||||
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask;
|
||||||
|
public Task<int> DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0);
|
||||||
public void Delete(Conversation value) { }
|
public void Delete(Conversation value) { }
|
||||||
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask;
|
||||||
}
|
}
|
||||||
|
|||||||
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
56
backend/tests/Health.Tests/diet_commentary_tests.cs
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
using Health.Application.AI;
|
||||||
|
using Health.Domain.Entities;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.Tests;
|
||||||
|
|
||||||
|
public sealed class DietCommentaryTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FoodDescription_ContainsOnlyStructuredFoodData()
|
||||||
|
{
|
||||||
|
var result = DietCommentaryPolicy.BuildFoodDescription(
|
||||||
|
[
|
||||||
|
new DietCommentaryFood("番茄炒蛋", "半盘", 220),
|
||||||
|
new DietCommentaryFood("米饭", "一碗", 260),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Assert.Equal("番茄炒蛋(半盘,220千卡)、米饭(一碗,260千卡)", result);
|
||||||
|
Assert.DoesNotContain("请结合", result);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void LegacyArtifact_RequiresEveryUserMessageToUseInternalPrefix()
|
||||||
|
{
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
},
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.Assistant,
|
||||||
|
Content = "注意搭配蔬菜",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Messages =
|
||||||
|
[
|
||||||
|
new ConversationMessage
|
||||||
|
{
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "我想了解今天的饮食记录",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.True(DietCommentaryPolicy.IsLegacyArtifact(artifact.Messages));
|
||||||
|
Assert.False(DietCommentaryPolicy.IsLegacyArtifact(normal.Messages));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
using Health.Application.Medications;
|
using Health.Application.Medications;
|
||||||
|
using Health.Application.AI;
|
||||||
using Health.Domain.Entities;
|
using Health.Domain.Entities;
|
||||||
using Health.Domain.Enums;
|
using Health.Domain.Enums;
|
||||||
using Health.Infrastructure.AI;
|
using Health.Infrastructure.AI;
|
||||||
@@ -13,6 +14,50 @@ namespace Health.Tests;
|
|||||||
|
|
||||||
public sealed class PersistencePipelineTests
|
public sealed class PersistencePipelineTests
|
||||||
{
|
{
|
||||||
|
[Fact]
|
||||||
|
public async Task ConversationCleanup_DeletesOnlyLegacyDietCommentaryArtifacts()
|
||||||
|
{
|
||||||
|
await using var db = CreateDbContext();
|
||||||
|
var userId = Guid.NewGuid();
|
||||||
|
var artifact = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
artifact.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = artifact.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = DietCommentaryPolicy.LegacyPromptPrefix + "食物为:米饭",
|
||||||
|
});
|
||||||
|
var normal = new Conversation
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
UserId = userId,
|
||||||
|
AgentType = AgentType.Default,
|
||||||
|
};
|
||||||
|
normal.Messages.Add(new ConversationMessage
|
||||||
|
{
|
||||||
|
Id = Guid.NewGuid(),
|
||||||
|
ConversationId = normal.Id,
|
||||||
|
Role = MessageRole.User,
|
||||||
|
Content = "请分析我今天吃的米饭",
|
||||||
|
});
|
||||||
|
db.Conversations.AddRange(artifact, normal);
|
||||||
|
await db.SaveChangesAsync();
|
||||||
|
var repository = new EfAiConversationRepository(db);
|
||||||
|
|
||||||
|
var deleted = await repository.DeleteLegacyDietCommentaryArtifactsAsync(
|
||||||
|
userId,
|
||||||
|
DietCommentaryPolicy.LegacyPromptPrefix,
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.Equal(1, deleted);
|
||||||
|
Assert.Equal(normal.Id, Assert.Single(db.Conversations).Id);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -104,6 +104,7 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
final stack = ref.watch(routeStackProvider);
|
final stack = ref.watch(routeStackProvider);
|
||||||
final current = stack.last;
|
final current = stack.last;
|
||||||
final authState = ref.watch(authProvider);
|
final authState = ref.watch(authProvider);
|
||||||
|
final isPublicStaticText = current.name == 'staticText';
|
||||||
|
|
||||||
// 登录后自动跳转(在下一帧完成,无闪烁)
|
// 登录后自动跳转(在下一帧完成,无闪烁)
|
||||||
if (authState.isLoggedIn && current.name == 'login') {
|
if (authState.isLoggedIn && current.name == 'login') {
|
||||||
@@ -120,7 +121,8 @@ class _RootNavigator extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
if (!authState.isLoading &&
|
if (!authState.isLoading &&
|
||||||
!authState.isLoggedIn &&
|
!authState.isLoggedIn &&
|
||||||
current.name != 'login') {
|
current.name != 'login' &&
|
||||||
|
!isPublicStaticText) {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
goRoute(ref, 'login');
|
goRoute(ref, 'login');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import 'local_database.dart';
|
|||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.221.78:5000',
|
defaultValue: 'http://10.4.251.10:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -20,30 +20,30 @@ class AppColors {
|
|||||||
static const Color meadowAccent = Color(0xFFBAE6FD);
|
static const Color meadowAccent = Color(0xFFBAE6FD);
|
||||||
static const Color sageAccent = Color(0xFFE9D5FF);
|
static const Color sageAccent = Color(0xFFE9D5FF);
|
||||||
|
|
||||||
static const Color health = Color(0xFF3B82F6);
|
static const Color health = Color(0xFF34D399);
|
||||||
static const Color healthLight = Color(0xFFEFF6FF);
|
static const Color healthLight = Color(0xFFF0FDF7);
|
||||||
static const Color healthBorder = Color(0xFFBFDBFE);
|
static const Color healthBorder = Color(0xFFC8F7E1);
|
||||||
static const Color medication = Color(0xFF8B5CF6);
|
static const Color medication = Color(0xFF00B8D9);
|
||||||
static const Color medicationLight = Color(0xFFF5F3FF);
|
static const Color medicationLight = Color(0xFFEAFBFF);
|
||||||
static const Color medicationBorder = Color(0xFFDDD6FE);
|
static const Color medicationBorder = Color(0xFFB8F0FA);
|
||||||
static const Color exercise = Color(0xFF10B981);
|
static const Color exercise = Color(0xFF8EC5FC);
|
||||||
static const Color exerciseLight = Color(0xFFECFDF5);
|
static const Color exerciseLight = Color(0xFFF5F0FF);
|
||||||
static const Color exerciseBorder = Color(0xFFA7F3D0);
|
static const Color exerciseBorder = Color(0xFFDCCBFE);
|
||||||
static const Color report = Color(0xFF2563EB);
|
static const Color report = Color(0xFF6366F1);
|
||||||
static const Color reportLight = Color(0xFFEEF2FF);
|
static const Color reportLight = Color(0xFFEFFBFF);
|
||||||
static const Color reportBorder = Color(0xFFC7D2FE);
|
static const Color reportBorder = Color(0xFFBAE6FD);
|
||||||
static const Color diet = Color(0xFFF97316);
|
static const Color diet = Color(0xFFF7971E);
|
||||||
static const Color dietLight = Color(0xFFFFF7ED);
|
static const Color dietLight = Color(0xFFFFF8DB);
|
||||||
static const Color dietBorder = Color(0xFFFED7AA);
|
static const Color dietBorder = Color(0xFFFFE58A);
|
||||||
static const Color device = Color(0xFF06B6D4);
|
static const Color device = Color(0xFF2563EB);
|
||||||
static const Color deviceLight = Color(0xFFECFEFF);
|
static const Color deviceLight = Color(0xFFEFF6FF);
|
||||||
static const Color deviceBorder = Color(0xFFA5F3FC);
|
static const Color deviceBorder = Color(0xFFBFDBFE);
|
||||||
static const Color notification = Color(0xFF7C5CFF);
|
static const Color notification = Color(0xFF7C5CFF);
|
||||||
static const Color notificationLight = Color(0xFFF5F3FF);
|
static const Color notificationLight = Color(0xFFF5F3FF);
|
||||||
static const Color notificationBorder = Color(0xFFDDD6FE);
|
static const Color notificationBorder = Color(0xFFDDD6FE);
|
||||||
static const Color doctor = Color(0xFF0F766E);
|
static const Color doctor = Color(0xFFFFAFBD);
|
||||||
static const Color doctorLight = Color(0xFFF0FDFA);
|
static const Color doctorLight = Color(0xFFFFF3EF);
|
||||||
static const Color doctorBorder = Color(0xFF99F6E4);
|
static const Color doctorBorder = Color(0xFFFFD7CB);
|
||||||
static const Color calendar = Color(0xFF6366F1);
|
static const Color calendar = Color(0xFF6366F1);
|
||||||
static const Color calendarLight = Color(0xFFEEF2FF);
|
static const Color calendarLight = Color(0xFFEEF2FF);
|
||||||
static const Color calendarBorder = Color(0xFFC7D2FE);
|
static const Color calendarBorder = Color(0xFFC7D2FE);
|
||||||
@@ -157,45 +157,45 @@ class AppColors {
|
|||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient doctorGradient = LinearGradient(
|
static const LinearGradient doctorGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient healthGradient = LinearGradient(
|
static const LinearGradient healthGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.centerLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.centerRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient medicationGradient = LinearGradient(
|
static const LinearGradient medicationGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient exerciseGradient = LinearGradient(
|
static const LinearGradient exerciseGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF34D399), Color(0xFF059669)],
|
colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient reportGradient = LinearGradient(
|
static const LinearGradient reportGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
colors: [Color(0xFF38BDF8), Color(0xFF818CF8)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient dietGradient = LinearGradient(
|
static const LinearGradient dietGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFB923C), Color(0xFFF97316)],
|
colors: [Color(0xFFF7971E), Color(0xFFFFD200)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient deviceGradient = LinearGradient(
|
static const LinearGradient deviceGradient = LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFF22D3EE), Color(0xFF0891B2)],
|
colors: [Color(0xFF60A5FA), Color(0xFF2563EB)],
|
||||||
);
|
);
|
||||||
|
|
||||||
static const LinearGradient notificationGradient = LinearGradient(
|
static const LinearGradient notificationGradient = LinearGradient(
|
||||||
|
|||||||
@@ -32,7 +32,9 @@ class RouteStackNotifier extends Notifier<List<RouteInfo>> {
|
|||||||
|
|
||||||
/// 路由栈 Provider
|
/// 路由栈 Provider
|
||||||
final routeStackProvider =
|
final routeStackProvider =
|
||||||
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(RouteStackNotifier.new);
|
NotifierProvider<RouteStackNotifier, List<RouteInfo>>(
|
||||||
|
RouteStackNotifier.new,
|
||||||
|
);
|
||||||
|
|
||||||
/// 当前路由
|
/// 当前路由
|
||||||
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
final currentRouteProvider = Provider<RouteInfo>((ref) {
|
||||||
@@ -46,13 +48,21 @@ void _dismissKeyboard() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 跳转(替换整个栈)
|
/// 跳转(替换整个栈)
|
||||||
void goRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void goRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
ref.read(routeStackProvider.notifier).replace(name, params: params);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 推入新页面
|
/// 推入新页面
|
||||||
void pushRoute(WidgetRef ref, String name, {Map<String, String> params = const {}}) {
|
void pushRoute(
|
||||||
|
WidgetRef ref,
|
||||||
|
String name, {
|
||||||
|
Map<String, String> params = const {},
|
||||||
|
}) {
|
||||||
_dismissKeyboard();
|
_dismissKeyboard();
|
||||||
ref.read(routeStackProvider.notifier).push(name, params: params);
|
ref.read(routeStackProvider.notifier).push(name, params: params);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
|
import '../../widgets/app_gradient_widgets.dart';
|
||||||
|
|
||||||
class LoginPage extends ConsumerStatefulWidget {
|
class LoginPage extends ConsumerStatefulWidget {
|
||||||
const LoginPage({super.key});
|
const LoginPage({super.key});
|
||||||
@@ -13,6 +14,11 @@ class LoginPage extends ConsumerStatefulWidget {
|
|||||||
|
|
||||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||||
static const _loginBg = 'assets/branding/login_background_v1.png';
|
static const _loginBg = 'assets/branding/login_background_v1.png';
|
||||||
|
static const _loginActionGradient = AppColors.primaryGradient;
|
||||||
|
|
||||||
|
void _openStaticText(String type) {
|
||||||
|
pushRoute(ref, 'staticText', params: {'type': type});
|
||||||
|
}
|
||||||
|
|
||||||
final _phoneCtrl = TextEditingController();
|
final _phoneCtrl = TextEditingController();
|
||||||
final _codeCtrl = TextEditingController();
|
final _codeCtrl = TextEditingController();
|
||||||
@@ -174,23 +180,51 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
RichText(
|
RichText(
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
text: const TextSpan(
|
text: TextSpan(
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
height: 1.55,
|
height: 1.55,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textSecondary,
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
TextSpan(text: '请阅读并同意'),
|
const TextSpan(text: '请阅读并同意'),
|
||||||
TextSpan(
|
WidgetSpan(
|
||||||
text: '《服务协议》',
|
alignment: PlaceholderAlignment.baseline,
|
||||||
style: TextStyle(color: AppColors.primary),
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx, false);
|
||||||
|
_openStaticText('terms');
|
||||||
|
},
|
||||||
|
child: AppGradientText(
|
||||||
|
'《服务协议》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
TextSpan(text: '和'),
|
const TextSpan(text: '和'),
|
||||||
TextSpan(
|
WidgetSpan(
|
||||||
text: '《隐私政策》',
|
alignment: PlaceholderAlignment.baseline,
|
||||||
style: TextStyle(color: AppColors.primary),
|
baseline: TextBaseline.alphabetic,
|
||||||
|
child: GestureDetector(
|
||||||
|
onTap: () {
|
||||||
|
Navigator.pop(ctx, false);
|
||||||
|
_openStaticText('privacy');
|
||||||
|
},
|
||||||
|
child: AppGradientText(
|
||||||
|
'《隐私政策》',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
height: 1.55,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -199,26 +233,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: SizedBox(
|
child: AppGradientOutlineButton(
|
||||||
height: 50,
|
label: '不同意',
|
||||||
child: OutlinedButton(
|
onTap: () => Navigator.pop(ctx, false),
|
||||||
onPressed: () => Navigator.pop(ctx, false),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.primary,
|
|
||||||
side: BorderSide(
|
|
||||||
color: AppColors.primary.withValues(alpha: 0.62),
|
|
||||||
width: 1.2,
|
|
||||||
),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
),
|
|
||||||
textStyle: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: const Text('不同意'),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@@ -229,7 +246,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
height: 50,
|
height: 50,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.doctorGradient,
|
gradient: _loginActionGradient,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
boxShadow: AppColors.buttonShadow,
|
boxShadow: AppColors.buttonShadow,
|
||||||
),
|
),
|
||||||
@@ -328,9 +345,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
trailing: _selectedDoctorId == d['id']?.toString()
|
trailing: _selectedDoctorId == d['id']?.toString()
|
||||||
? const Icon(
|
? const AppGradientIcon(
|
||||||
Icons.check_circle,
|
icon: Icons.check_circle,
|
||||||
color: AppColors.primary,
|
size: 22,
|
||||||
)
|
)
|
||||||
: null,
|
: null,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@@ -368,6 +385,7 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
|
resizeToAvoidBottomInset: false,
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
@@ -475,6 +493,9 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
agreed: _agreed,
|
agreed: _agreed,
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
setState(() => _agreed = !_agreed),
|
setState(() => _agreed = !_agreed),
|
||||||
|
onTermsTap: () => _openStaticText('terms'),
|
||||||
|
onPrivacyTap: () =>
|
||||||
|
_openStaticText('privacy'),
|
||||||
),
|
),
|
||||||
if (_error != null) ...[
|
if (_error != null) ...[
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
@@ -496,13 +517,13 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
|||||||
_error = null;
|
_error = null;
|
||||||
_successMsg = null;
|
_successMsg = null;
|
||||||
}),
|
}),
|
||||||
child: Text(
|
child: Center(
|
||||||
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
child: AppGradientText(
|
||||||
textAlign: TextAlign.center,
|
_isLogin ? '没有账号?去注册' : '已有账号?去登录',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.primary,
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -645,7 +666,7 @@ class _TextField extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
focusedBorder: OutlineInputBorder(
|
focusedBorder: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
borderSide: const BorderSide(color: AppColors.primary, width: 1.2),
|
borderSide: const BorderSide(color: AppColors.auraIndigo, width: 1.2),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -657,10 +678,10 @@ class _GradientPrefixIcon extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ShaderMask(
|
return AppGradientIcon(
|
||||||
shaderCallback: AppColors.doctorGradient.createShader,
|
icon: icon,
|
||||||
blendMode: BlendMode.srcIn,
|
size: 23,
|
||||||
child: Icon(icon, size: 23, color: Colors.white),
|
gradient: _LoginPageState._loginActionGradient,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -730,7 +751,7 @@ class _SmsButton extends StatelessWidget {
|
|||||||
height: 50,
|
height: 50,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: disabled ? null : AppColors.doctorGradient,
|
gradient: disabled ? null : _LoginPageState._loginActionGradient,
|
||||||
color: disabled ? AppColors.cardInner : null,
|
color: disabled ? AppColors.cardInner : null,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
@@ -757,64 +778,67 @@ class _SmsButton extends StatelessWidget {
|
|||||||
class _Agreement extends StatelessWidget {
|
class _Agreement extends StatelessWidget {
|
||||||
final bool agreed;
|
final bool agreed;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
const _Agreement({required this.agreed, required this.onTap});
|
final VoidCallback onTermsTap;
|
||||||
|
final VoidCallback onPrivacyTap;
|
||||||
|
const _Agreement({
|
||||||
|
required this.agreed,
|
||||||
|
required this.onTap,
|
||||||
|
required this.onTermsTap,
|
||||||
|
required this.onPrivacyTap,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return GestureDetector(
|
const textStyle = TextStyle(fontSize: 13, color: AppColors.textHint);
|
||||||
onTap: onTap,
|
const linkStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w700);
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
return Row(
|
||||||
children: [
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
Container(
|
children: [
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: Container(
|
||||||
width: 18,
|
width: 18,
|
||||||
height: 18,
|
height: 18,
|
||||||
margin: const EdgeInsets.only(right: 7, top: 1),
|
margin: const EdgeInsets.only(right: 7),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: agreed ? AppColors.primary : Colors.white,
|
gradient: agreed ? _LoginPageState._loginActionGradient : null,
|
||||||
|
color: agreed ? null : Colors.white,
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: agreed ? AppColors.primary : AppColors.border,
|
color: agreed ? Colors.transparent : AppColors.border,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: agreed
|
child: agreed
|
||||||
? const Icon(Icons.check, size: 13, color: Colors.white)
|
? const Icon(Icons.check, size: 13, color: Colors.white)
|
||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
Flexible(
|
),
|
||||||
child: RichText(
|
Expanded(
|
||||||
text: const TextSpan(
|
child: Wrap(
|
||||||
children: [
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
TextSpan(
|
children: [
|
||||||
text: '已阅读并同意',
|
GestureDetector(
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
onTap: onTap,
|
||||||
),
|
behavior: HitTestBehavior.opaque,
|
||||||
TextSpan(
|
child: const Text('已阅读并同意', style: textStyle),
|
||||||
text: '《服务协议》',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: '和',
|
|
||||||
style: TextStyle(fontSize: 12, color: AppColors.textHint),
|
|
||||||
),
|
|
||||||
TextSpan(
|
|
||||||
text: '《隐私政策》',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.primary,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
GestureDetector(
|
||||||
|
onTap: onTermsTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const AppGradientText('《服务协议》', style: linkStyle),
|
||||||
|
),
|
||||||
|
const Text('和', style: textStyle),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: onPrivacyTap,
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
child: const AppGradientText('《隐私政策》', style: linkStyle),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -882,7 +906,7 @@ class _PrimaryButton extends StatelessWidget {
|
|||||||
height: 52,
|
height: 52,
|
||||||
alignment: Alignment.center,
|
alignment: Alignment.center,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.doctorGradient,
|
gradient: _LoginPageState._loginActionGradient,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
boxShadow: AppColors.buttonShadow,
|
boxShadow: AppColors.buttonShadow,
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -814,6 +814,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
|||||||
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
_filtered.removeWhere((x) => x['id']?.toString() == id);
|
||||||
_selectedIdx = null;
|
_selectedIdx = null;
|
||||||
});
|
});
|
||||||
|
ref.invalidate(latestHealthProvider);
|
||||||
return true;
|
return true;
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -685,12 +685,12 @@ class _EmptyDevices extends StatelessWidget {
|
|||||||
width: 72,
|
width: 72,
|
||||||
height: 72,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFEFF6FF),
|
color: AppColors.deviceLight,
|
||||||
borderRadius: BorderRadius.circular(22),
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_disabled_rounded,
|
Icons.bluetooth_disabled_rounded,
|
||||||
color: Color(0xFF2563EB),
|
color: AppColors.device,
|
||||||
size: 36,
|
size: 36,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -747,7 +747,7 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
height: 78 * animation.value,
|
height: 78 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
|
color: AppColors.device.withValues(alpha: 0.10),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -758,7 +758,7 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
height: 64 * animation.value,
|
height: 64 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.18),
|
color: AppColors.device.withValues(alpha: 0.18),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -767,11 +767,11 @@ class _ScanIndicator extends StatelessWidget {
|
|||||||
width: 58,
|
width: 58,
|
||||||
height: 58,
|
height: 58,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFF2563EB),
|
color: AppColors.device,
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF2563EB).withValues(alpha: 0.24),
|
color: AppColors.device.withValues(alpha: 0.24),
|
||||||
blurRadius: 18,
|
blurRadius: 18,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -439,15 +439,13 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
width: 48,
|
width: 48,
|
||||||
height: 48,
|
height: 48,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.primary.withValues(alpha: 0.10),
|
color: AppColors.deviceLight,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(color: AppColors.deviceBorder),
|
||||||
color: AppColors.primary.withValues(alpha: 0.10),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: Icon(
|
child: Icon(
|
||||||
type?.icon ?? Icons.bluetooth_rounded,
|
type?.icon ?? Icons.bluetooth_rounded,
|
||||||
color: AppColors.primary,
|
color: AppColors.device,
|
||||||
size: 25,
|
size: 25,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -483,6 +481,10 @@ class _DeviceResultTile extends StatelessWidget {
|
|||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: connecting ? null : onConnect,
|
onPressed: connecting ? null : onConnect,
|
||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: AppColors.device,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
disabledBackgroundColor: AppColors.cardInner,
|
||||||
|
disabledForegroundColor: AppColors.textHint,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
@@ -526,7 +528,7 @@ class _ScanPulse extends StatelessWidget {
|
|||||||
height: 78 * animation.value,
|
height: 78 * animation.value,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
color: AppColors.primary.withValues(alpha: 0.08),
|
color: AppColors.device.withValues(alpha: 0.08),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -541,7 +543,7 @@ class _ScanPulse extends StatelessWidget {
|
|||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.bluetooth_searching_rounded,
|
Icons.bluetooth_searching_rounded,
|
||||||
size: 32,
|
size: 32,
|
||||||
color: AppColors.primary,
|
color: AppColors.device,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
15
health_app/lib/pages/diet/diet_record_logic.dart
Normal file
15
health_app/lib/pages/diet/diet_record_logic.dart
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
List<DateTime> recentDietDates(DateTime now, {int count = 7}) {
|
||||||
|
final today = DateTime(now.year, now.month, now.day);
|
||||||
|
return List.generate(
|
||||||
|
count,
|
||||||
|
(index) => today.subtract(Duration(days: count - 1 - index)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool isSavableFood({
|
||||||
|
required String name,
|
||||||
|
required String portion,
|
||||||
|
required int calories,
|
||||||
|
}) {
|
||||||
|
return name.trim().isNotEmpty && portion.trim().isNotEmpty && calories > 0;
|
||||||
|
}
|
||||||
@@ -263,10 +263,10 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
const SizedBox(height: 9),
|
const SizedBox(height: 9),
|
||||||
Text(
|
Text(
|
||||||
info.$2,
|
info.$2,
|
||||||
style: AppTextStyles.chatTitle.copyWith(
|
style: AppTextStyles.chatTitle.copyWith(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
@@ -473,6 +473,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
backendType != 'exercise');
|
backendType != 'exercise');
|
||||||
final isExercise = backendType == 'exercise';
|
final isExercise = backendType == 'exercise';
|
||||||
final isHealth = !isMedication && !isExercise;
|
final isHealth = !isMedication && !isExercise;
|
||||||
|
final visual = isMedication
|
||||||
|
? AppModuleVisuals.medication
|
||||||
|
: isExercise
|
||||||
|
? AppModuleVisuals.exercise
|
||||||
|
: AppModuleVisuals.health;
|
||||||
|
|
||||||
// 根据类型获取显示字段
|
// 根据类型获取显示字段
|
||||||
String title = '数据确认';
|
String title = '数据确认';
|
||||||
@@ -585,7 +590,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
borderRadius: BorderRadius.circular(28),
|
borderRadius: BorderRadius.circular(28),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF6366F1).withValues(alpha: 0.10),
|
color: visual.color.withValues(alpha: 0.10),
|
||||||
blurRadius: 28,
|
blurRadius: 28,
|
||||||
offset: const Offset(0, 14),
|
offset: const Offset(0, 14),
|
||||||
),
|
),
|
||||||
@@ -604,11 +609,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
padding: const EdgeInsets.fromLTRB(20, 20, 20, 18),
|
||||||
decoration: const BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.topLeft,
|
begin: Alignment.topLeft,
|
||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)],
|
colors: [Colors.white, visual.lightColor],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -618,11 +623,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 56,
|
width: 56,
|
||||||
height: 56,
|
height: 56,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(16),
|
borderRadius: BorderRadius.circular(16),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.auraIndigo.withValues(alpha: 0.18),
|
color: visual.color.withValues(alpha: 0.18),
|
||||||
blurRadius: 10,
|
blurRadius: 10,
|
||||||
offset: const Offset(0, 4),
|
offset: const Offset(0, 4),
|
||||||
),
|
),
|
||||||
@@ -674,7 +679,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(1.4),
|
padding: const EdgeInsets.all(1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
),
|
),
|
||||||
child: Container(
|
child: Container(
|
||||||
@@ -691,7 +696,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 68,
|
width: 68,
|
||||||
height: 68,
|
height: 68,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.lightGradient,
|
color: visual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(18),
|
||||||
),
|
),
|
||||||
child: Center(
|
child: Center(
|
||||||
@@ -699,14 +704,14 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
? Icon(
|
? Icon(
|
||||||
_getMetricIconData(backendType),
|
_getMetricIconData(backendType),
|
||||||
size: 34,
|
size: 34,
|
||||||
color: AppColors.primaryDark,
|
color: visual.color,
|
||||||
)
|
)
|
||||||
: Icon(
|
: Icon(
|
||||||
isMedication
|
isMedication
|
||||||
? Icons.medication_liquid_outlined
|
? Icons.medication_liquid_outlined
|
||||||
: LucideIcons.footprints,
|
: LucideIcons.footprints,
|
||||||
size: 36,
|
size: 36,
|
||||||
color: AppColors.iconColor,
|
color: visual.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -745,7 +750,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
fontSize: 19,
|
fontSize: 19,
|
||||||
color: abnormal
|
color: abnormal
|
||||||
? AppColors.error
|
? AppColors.error
|
||||||
: AppColors.iconColor,
|
: visual.color,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -849,11 +854,11 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
height: 56,
|
height: 56,
|
||||||
padding: const EdgeInsets.all(1.4),
|
padding: const EdgeInsets.all(1.4),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: AppColors.auraIndigo.withValues(alpha: 0.18),
|
color: visual.color.withValues(alpha: 0.18),
|
||||||
blurRadius: 18,
|
blurRadius: 18,
|
||||||
offset: const Offset(0, 8),
|
offset: const Offset(0, 8),
|
||||||
),
|
),
|
||||||
@@ -913,7 +918,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
width: 32,
|
width: 32,
|
||||||
height: 32,
|
height: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.actionOutlineGradient,
|
gradient: visual.gradient,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(10),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -1268,7 +1273,6 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
return path;
|
return path;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/// 处理 AI 回复里的 markdown 链接点击:
|
/// 处理 AI 回复里的 markdown 链接点击:
|
||||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||||
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||||
@@ -1504,27 +1508,58 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
wtText == null;
|
wtText == null;
|
||||||
|
|
||||||
// ── 多指标异常检测 ──
|
// ── 多指标异常检测 ──
|
||||||
|
// 优先使用后端保存时计算出的 IsAbnormal,兜底规则必须和后端 HealthRecordRules 保持一致。
|
||||||
final abnormals = <String>[];
|
final abnormals = <String>[];
|
||||||
if (bp is Map && bp['systolic'] is int) {
|
final bpAbnormal = _recordIsAbnormal(
|
||||||
final s = bp['systolic'] as int;
|
bp,
|
||||||
final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0;
|
fallback: () {
|
||||||
if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高');
|
if (bp is! Map) return false;
|
||||||
|
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||||||
|
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||||||
|
return s != null &&
|
||||||
|
d != null &&
|
||||||
|
(s >= 140 || d >= 90 || s <= 89 || d <= 59);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (bpAbnormal && bp is Map) {
|
||||||
|
final s = _numValue(bp['systolic'] ?? bp['Systolic']);
|
||||||
|
final d = _numValue(bp['diastolic'] ?? bp['Diastolic']);
|
||||||
|
abnormals.add(
|
||||||
|
'血压 ${s?.toStringAsFixed(0) ?? '--'}/${d?.toStringAsFixed(0) ?? '--'} 异常',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
if (hr is Map && hr['value'] is num) {
|
final hrAbnormal = _recordIsAbnormal(
|
||||||
final v = (hr['value'] as num).toDouble();
|
hr,
|
||||||
if (v > 100) {
|
fallback: () {
|
||||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
final v = _numValue(hr is Map ? hr['value'] ?? hr['Value'] : null);
|
||||||
} else if (v < 60) {
|
return v != null && (v > 100 || v < 60);
|
||||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
},
|
||||||
}
|
);
|
||||||
|
if (hrAbnormal && hr is Map) {
|
||||||
|
final v = _numValue(hr['value'] ?? hr['Value']);
|
||||||
|
abnormals.add('心率 ${v?.toStringAsFixed(0) ?? '--'} 异常');
|
||||||
}
|
}
|
||||||
if (bs is Map && bs['value'] is num) {
|
final bsAbnormal = _recordIsAbnormal(
|
||||||
final v = (bs['value'] as num).toDouble();
|
bs,
|
||||||
if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高');
|
fallback: () {
|
||||||
|
final v = _numValue(bs is Map ? bs['value'] ?? bs['Value'] : null);
|
||||||
|
return v != null && (v > 6.1 || v < 3.9);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (bsAbnormal && bs is Map) {
|
||||||
|
final v = _numValue(bs['value'] ?? bs['Value']);
|
||||||
|
abnormals.add('血糖 ${v?.toStringAsFixed(1) ?? '--'} 异常');
|
||||||
}
|
}
|
||||||
if (bo is Map && bo['value'] is num) {
|
final boAbnormal = _recordIsAbnormal(
|
||||||
final v = (bo['value'] as num).toDouble();
|
bo,
|
||||||
if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低');
|
fallback: () {
|
||||||
|
final v = _numValue(bo is Map ? bo['value'] ?? bo['Value'] : null);
|
||||||
|
return v != null && v <= 94;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if (boAbnormal && bo is Map) {
|
||||||
|
final v = _numValue(bo['value'] ?? bo['Value']);
|
||||||
|
abnormals.add('血氧 ${v?.toStringAsFixed(0) ?? '--'}% 异常');
|
||||||
}
|
}
|
||||||
final hasAbnormal = abnormals.isNotEmpty;
|
final hasAbnormal = abnormals.isNotEmpty;
|
||||||
|
|
||||||
@@ -1545,8 +1580,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: trailing,
|
trailing: trailing,
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
iconColor: healthIconColor,
|
iconColor: AppColors.warning,
|
||||||
iconBg: healthIconBg,
|
iconBg: AppColors.warningLight,
|
||||||
onTap: () => pushRoute(ref, 'trend'),
|
onTap: () => pushRoute(ref, 'trend'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -1749,7 +1784,7 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 10),
|
padding: const EdgeInsets.fromLTRB(16, 13, 16, 11),
|
||||||
decoration: const BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
gradient: LinearGradient(
|
gradient: LinearGradient(
|
||||||
begin: Alignment.centerLeft,
|
begin: Alignment.centerLeft,
|
||||||
@@ -1760,26 +1795,13 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
|
||||||
width: 28,
|
|
||||||
height: 28,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.6),
|
|
||||||
borderRadius: BorderRadius.circular(8),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
Icons.health_and_safety,
|
|
||||||
size: 16,
|
|
||||||
color: AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
const Text(
|
const Text(
|
||||||
'今日健康',
|
'今日健康',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.15,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@@ -1867,6 +1889,8 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
trailing ?? label,
|
trailing ?? label,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 16,
|
fontSize: 16,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
@@ -1884,6 +1908,27 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool _recordIsAbnormal(dynamic record, {required bool Function() fallback}) {
|
||||||
|
if (record is Map) {
|
||||||
|
final raw =
|
||||||
|
record['isAbnormal'] ?? record['IsAbnormal'] ?? record['abnormal'];
|
||||||
|
if (raw is bool) return raw;
|
||||||
|
if (raw is String) {
|
||||||
|
final normalized = raw.toLowerCase();
|
||||||
|
if (normalized == 'true') return true;
|
||||||
|
if (normalized == 'false') return false;
|
||||||
|
}
|
||||||
|
if (raw is num) return raw != 0;
|
||||||
|
}
|
||||||
|
return fallback();
|
||||||
|
}
|
||||||
|
|
||||||
|
double? _numValue(dynamic value) {
|
||||||
|
if (value is num) return value.toDouble();
|
||||||
|
if (value is String) return double.tryParse(value);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _AgentMark extends StatelessWidget {
|
class _AgentMark extends StatelessWidget {
|
||||||
@@ -1906,7 +1951,7 @@ class _AgentMark extends StatelessWidget {
|
|||||||
? Alignment.bottomCenter
|
? Alignment.bottomCenter
|
||||||
: Alignment.bottomRight,
|
: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(colors.verticalGradient ? 18 : 20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: colors.accent.withValues(alpha: 0.18),
|
color: colors.accent.withValues(alpha: 0.18),
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_module_visuals.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/auth_provider.dart';
|
import '../../providers/auth_provider.dart';
|
||||||
@@ -21,8 +22,7 @@ class MedicationCheckInPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||||
static const _medBlue = Color(0xFF60A5FA);
|
static const _visual = AppModuleVisuals.medication;
|
||||||
static const _medViolet = Color(0xFF8B5CF6);
|
|
||||||
static const _softGreen = Color(0xFFEAF8EF);
|
static const _softGreen = Color(0xFFEAF8EF);
|
||||||
|
|
||||||
Future<List<Map<String, dynamic>>>? _future;
|
Future<List<Map<String, dynamic>>>? _future;
|
||||||
@@ -67,11 +67,7 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('[MedCheckIn] 打卡失败: $e');
|
debugPrint('[MedCheckIn] 打卡失败: $e');
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
AppToast.show(
|
AppToast.show(context, '打卡失败,请稍后重试', type: AppToastType.error);
|
||||||
context,
|
|
||||||
'打卡失败,请稍后重试',
|
|
||||||
type: AppToastType.error,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (mounted) setState(() => _busyDoses.remove(doseKey));
|
if (mounted) setState(() => _busyDoses.remove(doseKey));
|
||||||
@@ -212,14 +208,7 @@ class _CheckInSummary extends StatelessWidget {
|
|||||||
width: 42,
|
width: 42,
|
||||||
height: 42,
|
height: 42,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
gradient: _MedicationCheckInPageState._visual.gradient,
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
_MedicationCheckInPageState._medBlue,
|
|
||||||
_MedicationCheckInPageState._medViolet,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
@@ -254,10 +243,10 @@ class _CheckInSummary extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
'${(progress * 100).round()}%',
|
'${(progress * 100).round()}%',
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 22,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w900,
|
||||||
color: _MedicationCheckInPageState._medViolet,
|
color: _MedicationCheckInPageState._visual.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -269,8 +258,8 @@ class _CheckInSummary extends StatelessWidget {
|
|||||||
minHeight: 9,
|
minHeight: 9,
|
||||||
value: progress,
|
value: progress,
|
||||||
backgroundColor: AppColors.cardInner,
|
backgroundColor: AppColors.cardInner,
|
||||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
valueColor: AlwaysStoppedAnimation<Color>(
|
||||||
_MedicationCheckInPageState._medBlue,
|
_MedicationCheckInPageState._visual.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -300,7 +289,7 @@ class _CheckInSummary extends StatelessWidget {
|
|||||||
label: '总次数',
|
label: '总次数',
|
||||||
value: '$total',
|
value: '$total',
|
||||||
icon: Icons.format_list_numbered_outlined,
|
icon: Icons.format_list_numbered_outlined,
|
||||||
color: _MedicationCheckInPageState._medViolet,
|
color: _MedicationCheckInPageState._visual.color,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -415,7 +404,7 @@ class _MedicationDoseCard extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: allTaken
|
color: allTaken
|
||||||
? _MedicationCheckInPageState._softGreen
|
? _MedicationCheckInPageState._softGreen
|
||||||
: AppColors.infoLight,
|
: _MedicationCheckInPageState._visual.lightColor,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
),
|
),
|
||||||
@@ -423,7 +412,7 @@ class _MedicationDoseCard extends StatelessWidget {
|
|||||||
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
|
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
|
||||||
color: allTaken
|
color: allTaken
|
||||||
? AppTheme.success
|
? AppTheme.success
|
||||||
: _MedicationCheckInPageState._medBlue,
|
: _MedicationCheckInPageState._visual.color,
|
||||||
size: 24,
|
size: 24,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -613,7 +602,7 @@ class _DoseRow extends StatelessWidget {
|
|||||||
style: FilledButton.styleFrom(
|
style: FilledButton.styleFrom(
|
||||||
backgroundColor: isTaken
|
backgroundColor: isTaken
|
||||||
? Colors.white
|
? Colors.white
|
||||||
: _MedicationCheckInPageState._medBlue,
|
: _MedicationCheckInPageState._visual.color,
|
||||||
foregroundColor: isTaken
|
foregroundColor: isTaken
|
||||||
? AppTheme.success
|
? AppTheme.success
|
||||||
: Colors.white,
|
: Colors.white,
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|||||||
|
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
import '../../core/app_design_tokens.dart';
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_module_visuals.dart';
|
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
import '../../providers/data_providers.dart';
|
import '../../providers/data_providers.dart';
|
||||||
@@ -19,8 +18,6 @@ class MedicationEditPage extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||||
static const _visual = AppModuleVisuals.medication;
|
|
||||||
|
|
||||||
final _nameCtrl = TextEditingController();
|
final _nameCtrl = TextEditingController();
|
||||||
final _dosageCtrl = TextEditingController();
|
final _dosageCtrl = TextEditingController();
|
||||||
final _notesCtrl = TextEditingController();
|
final _notesCtrl = TextEditingController();
|
||||||
@@ -202,7 +199,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: _visual.borderColor, width: 1),
|
border: Border.all(color: AppColors.border, width: 1),
|
||||||
),
|
),
|
||||||
child: Text(
|
child: Text(
|
||||||
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
'${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}',
|
||||||
@@ -235,19 +232,9 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
_field('备注', _notesCtrl, hint: '如:饭后服用、睡前'),
|
_field('备注', _notesCtrl, hint: '如:饭后服用、睡前'),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 32),
|
||||||
SizedBox(
|
_GradientOutlineButton(
|
||||||
width: double.infinity,
|
onPressed: _loading ? null : _save,
|
||||||
child: ElevatedButton(
|
label: _loading ? '保存中...' : '保存',
|
||||||
onPressed: _loading ? null : _save,
|
|
||||||
style: ElevatedButton.styleFrom(
|
|
||||||
backgroundColor: Colors.white,
|
|
||||||
foregroundColor: _visual.color,
|
|
||||||
side: BorderSide(color: _visual.color, width: 1.5),
|
|
||||||
shape: RoundedRectangleBorder(borderRadius: AppRadius.lgBorder),
|
|
||||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
|
||||||
),
|
|
||||||
child: Text(_loading ? '保存中...' : '保存', style: AppTextStyles.button),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
],
|
],
|
||||||
@@ -271,7 +258,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
|||||||
fillColor: AppTheme.surface,
|
fillColor: AppTheme.surface,
|
||||||
border: _inputBorder(AppColors.border),
|
border: _inputBorder(AppColors.border),
|
||||||
enabledBorder: _inputBorder(AppColors.border),
|
enabledBorder: _inputBorder(AppColors.border),
|
||||||
focusedBorder: _inputBorder(_visual.color),
|
focusedBorder: _inputBorder(AppColors.primary),
|
||||||
contentPadding: const EdgeInsets.symmetric(
|
contentPadding: const EdgeInsets.symmetric(
|
||||||
horizontal: 12,
|
horizontal: 12,
|
||||||
vertical: 12,
|
vertical: 12,
|
||||||
@@ -377,6 +364,57 @@ class _PickerBox extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _GradientOutlineButton extends StatelessWidget {
|
||||||
|
final VoidCallback? onPressed;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const _GradientOutlineButton({required this.onPressed, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final enabled = onPressed != null;
|
||||||
|
return Container(
|
||||||
|
width: double.infinity,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: enabled ? AppColors.actionOutlineGradient : null,
|
||||||
|
color: enabled ? null : AppColors.border,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(1.4),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onPressed,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: SizedBox(
|
||||||
|
height: 46,
|
||||||
|
child: Center(
|
||||||
|
child: enabled
|
||||||
|
? ShaderMask(
|
||||||
|
shaderCallback: (bounds) =>
|
||||||
|
AppColors.actionOutlineGradient.createShader(bounds),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: AppTextStyles.button.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: Text(
|
||||||
|
label,
|
||||||
|
style: AppTextStyles.button.copyWith(
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String _displayDate(DateTime date) {
|
String _displayDate(DateTime date) {
|
||||||
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,11 +50,9 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
),
|
),
|
||||||
title: const Text('用药管理'),
|
title: const Text('用药管理'),
|
||||||
),
|
),
|
||||||
floatingActionButton: FloatingActionButton(
|
floatingActionButton: _GradientFab(
|
||||||
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
gradient: _medVisual.gradient,
|
||||||
backgroundColor: _medVisual.color,
|
onTap: () => pushRoute(ref, 'medicationEdit'),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
|
||||||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||||
future: _future,
|
future: _future,
|
||||||
@@ -136,6 +134,41 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _GradientFab extends StatelessWidget {
|
||||||
|
final Gradient gradient;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const _GradientFab({required this.gradient, required this.onTap});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Container(
|
||||||
|
width: 56,
|
||||||
|
height: 56,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: gradient,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: AppColors.medication.withValues(alpha: 0.22),
|
||||||
|
blurRadius: 16,
|
||||||
|
offset: const Offset(0, 8),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: Material(
|
||||||
|
color: Colors.transparent,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
borderRadius: AppRadius.lgBorder,
|
||||||
|
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _MedicationListGroup extends StatelessWidget {
|
class _MedicationListGroup extends StatelessWidget {
|
||||||
final List<Widget> children;
|
final List<Widget> children;
|
||||||
|
|
||||||
|
|||||||
@@ -342,20 +342,24 @@ class _UnreadHint extends StatelessWidget {
|
|||||||
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFFFFBEB),
|
color: AppColors.notificationLight,
|
||||||
borderRadius: AppRadius.mdBorder,
|
borderRadius: AppRadius.mdBorder,
|
||||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
border: Border.all(color: AppColors.notificationBorder),
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)),
|
const Icon(
|
||||||
|
LucideIcons.bellRing,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.notification,
|
||||||
|
),
|
||||||
const SizedBox(width: 9),
|
const SizedBox(width: 9),
|
||||||
Text(
|
Text(
|
||||||
'你有 $unreadCount 条未读消息',
|
'你有 $unreadCount 条未读消息',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: Color(0xFF92400E),
|
color: AppColors.notification,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -370,23 +374,14 @@ class _SectionTitle extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => _SectionShell(
|
Widget build(BuildContext context) => _SectionShell(
|
||||||
child: Row(
|
child: Text(
|
||||||
children: [
|
text,
|
||||||
const Icon(
|
style: const TextStyle(
|
||||||
LucideIcons.calendarDays,
|
fontSize: 13,
|
||||||
size: 18,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.primary,
|
color: AppColors.textHint,
|
||||||
),
|
letterSpacing: 0,
|
||||||
const SizedBox(width: 8),
|
),
|
||||||
Text(
|
|
||||||
text,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -421,34 +416,29 @@ class _CollapsibleSectionTitle extends StatelessWidget {
|
|||||||
child: _SectionShell(
|
child: _SectionShell(
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
const Icon(
|
|
||||||
LucideIcons.history,
|
|
||||||
size: 18,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Text(
|
Text(
|
||||||
'$text · $count',
|
'$text · $count',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w900,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textHint,
|
||||||
|
letterSpacing: 0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
Text(
|
Text(
|
||||||
expanded ? '收起' : '展开',
|
expanded ? '收起' : '展开',
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 12,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 4),
|
||||||
Icon(
|
Icon(
|
||||||
expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||||
size: 22,
|
size: 18,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@@ -483,50 +473,17 @@ class _NotificationRow extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
padding: const EdgeInsets.fromLTRB(14, 13, 12, 12),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Stack(
|
Container(
|
||||||
clipBehavior: Clip.none,
|
width: 48,
|
||||||
children: [
|
height: 48,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
width: 48,
|
gradient: visual.gradient,
|
||||||
height: 48,
|
borderRadius: AppRadius.mdBorder,
|
||||||
decoration: BoxDecoration(
|
border: Border.all(
|
||||||
gradient: LinearGradient(
|
color: visual.borderColor.withValues(alpha: 0.85),
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [
|
|
||||||
visual.color.withValues(alpha: 0.18),
|
|
||||||
visual.lightColor,
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: AppRadius.mdBorder,
|
|
||||||
border: Border.all(
|
|
||||||
color: visual.color.withValues(alpha: 0.22),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
visual.icon,
|
|
||||||
size: 24,
|
|
||||||
color: visual.color,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
if (!item.isRead)
|
),
|
||||||
Positioned(
|
child: Icon(visual.icon, size: 24, color: Colors.white),
|
||||||
top: -2,
|
|
||||||
right: -2,
|
|
||||||
child: Container(
|
|
||||||
width: 10,
|
|
||||||
height: 10,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: AppColors.error,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
border: Border.all(
|
|
||||||
color: Colors.white,
|
|
||||||
width: 1.5,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 13),
|
const SizedBox(width: 13),
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -594,14 +551,22 @@ class _NotificationRow extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (item.actionType != null) ...[
|
const SizedBox(width: 10),
|
||||||
const SizedBox(width: 6),
|
SizedBox(
|
||||||
const Icon(
|
width: 12,
|
||||||
Icons.chevron_right_rounded,
|
child: Center(
|
||||||
size: 22,
|
child: item.isRead
|
||||||
color: AppColors.textSecondary,
|
? const SizedBox.shrink()
|
||||||
|
: Container(
|
||||||
|
width: 8,
|
||||||
|
height: 8,
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: AppColors.error,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -641,21 +606,33 @@ class _NotificationVisual {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
final Color lightColor;
|
final Color lightColor;
|
||||||
|
final Color borderColor;
|
||||||
|
final LinearGradient gradient;
|
||||||
final String label;
|
final String label;
|
||||||
|
|
||||||
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
const _NotificationVisual(
|
||||||
|
this.icon,
|
||||||
|
this.color,
|
||||||
|
this.lightColor,
|
||||||
|
this.borderColor,
|
||||||
|
this.gradient,
|
||||||
|
this.label,
|
||||||
|
);
|
||||||
|
|
||||||
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
|
factory _NotificationVisual.fromModule(AppModuleVisual visual) {
|
||||||
return _NotificationVisual(
|
return _NotificationVisual(
|
||||||
visual.icon,
|
visual.icon,
|
||||||
visual.color,
|
visual.color,
|
||||||
visual.lightColor,
|
visual.lightColor,
|
||||||
|
visual.borderColor,
|
||||||
|
visual.gradient,
|
||||||
visual.label,
|
visual.label,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
factory _NotificationVisual.of(InAppNotification item) {
|
factory _NotificationVisual.of(InAppNotification item) {
|
||||||
switch (item.actionType) {
|
final kind = item.actionType ?? item.type;
|
||||||
|
switch (kind) {
|
||||||
case 'exercise':
|
case 'exercise':
|
||||||
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
|
return _NotificationVisual.fromModule(AppModuleVisuals.exercise);
|
||||||
case 'health':
|
case 'health':
|
||||||
@@ -664,6 +641,12 @@ class _NotificationVisual {
|
|||||||
LucideIcons.triangleAlert,
|
LucideIcons.triangleAlert,
|
||||||
Color(0xFFEF4444),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
|
Color(0xFFFECACA),
|
||||||
|
LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)],
|
||||||
|
),
|
||||||
'紧急',
|
'紧急',
|
||||||
)
|
)
|
||||||
: _NotificationVisual.fromModule(AppModuleVisuals.health);
|
: _NotificationVisual.fromModule(AppModuleVisuals.health);
|
||||||
@@ -673,11 +656,21 @@ class _NotificationVisual {
|
|||||||
LucideIcons.fileWarning,
|
LucideIcons.fileWarning,
|
||||||
Color(0xFFEF4444),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFEE2E2),
|
Color(0xFFFEE2E2),
|
||||||
|
Color(0xFFFECACA),
|
||||||
|
LinearGradient(
|
||||||
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)],
|
||||||
|
),
|
||||||
'报告',
|
'报告',
|
||||||
)
|
)
|
||||||
: _NotificationVisual.fromModule(AppModuleVisuals.report);
|
: _NotificationVisual.fromModule(AppModuleVisuals.report);
|
||||||
default:
|
case 'medication':
|
||||||
return _NotificationVisual.fromModule(AppModuleVisuals.medication);
|
return _NotificationVisual.fromModule(AppModuleVisuals.medication);
|
||||||
|
case 'diet':
|
||||||
|
return _NotificationVisual.fromModule(AppModuleVisuals.diet);
|
||||||
|
default:
|
||||||
|
return _NotificationVisual.fromModule(AppModuleVisuals.notification);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../../core/app_colors.dart';
|
import '../../core/app_colors.dart';
|
||||||
|
import '../../core/app_design_tokens.dart';
|
||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
@@ -231,35 +232,35 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
if (prefs['healthRecord'] ?? true) ...[
|
if (prefs['healthRecord'] ?? true) ...[
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 血压',
|
title: '血压',
|
||||||
value: prefs['healthRecord.bp'] ?? true,
|
value: prefs['healthRecord.bp'] ?? true,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord.bp'),
|
.toggle('healthRecord.bp'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 心率',
|
title: '心率',
|
||||||
value: prefs['healthRecord.hr'] ?? true,
|
value: prefs['healthRecord.hr'] ?? true,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord.hr'),
|
.toggle('healthRecord.hr'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 血糖',
|
title: '血糖',
|
||||||
value: prefs['healthRecord.glucose'] ?? true,
|
value: prefs['healthRecord.glucose'] ?? true,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord.glucose'),
|
.toggle('healthRecord.glucose'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 血氧',
|
title: '血氧',
|
||||||
value: prefs['healthRecord.spo2'] ?? true,
|
value: prefs['healthRecord.spo2'] ?? true,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
.read(notificationPrefsProvider.notifier)
|
.read(notificationPrefsProvider.notifier)
|
||||||
.toggle('healthRecord.spo2'),
|
.toggle('healthRecord.spo2'),
|
||||||
),
|
),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
title: ' · 体重',
|
title: '体重',
|
||||||
value: prefs['healthRecord.weight'] ?? true,
|
value: prefs['healthRecord.weight'] ?? true,
|
||||||
showDivider: false,
|
showDivider: false,
|
||||||
onChanged: (v) => ref
|
onChanged: (v) => ref
|
||||||
@@ -391,7 +392,7 @@ class _SettingsListSection extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_SectionTitle(title: title),
|
_SectionTitle(title: title),
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
borderRadius: AppRadius.mdBorder,
|
||||||
child: ColoredBox(
|
child: ColoredBox(
|
||||||
color: AppTheme.surface,
|
color: AppTheme.surface,
|
||||||
child: Column(children: children),
|
child: Column(children: children),
|
||||||
@@ -425,71 +426,127 @@ class _SwitchTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
final dense = icon == null && (subtitle == null || subtitle!.isEmpty);
|
||||||
return Container(
|
return Container(
|
||||||
margin: EdgeInsets.zero,
|
margin: EdgeInsets.zero,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
decoration: BoxDecoration(color: AppTheme.surface),
|
||||||
decoration: BoxDecoration(
|
child: Column(
|
||||||
color: AppTheme.surface,
|
|
||||||
border: showDivider
|
|
||||||
? Border(
|
|
||||||
bottom: BorderSide(
|
|
||||||
color: AppColors.borderLight.withValues(alpha: 0.75),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
children: [
|
||||||
if (icon != null) ...[
|
Padding(
|
||||||
Container(
|
padding: EdgeInsets.symmetric(
|
||||||
width: 34,
|
horizontal: 14,
|
||||||
height: 34,
|
vertical: dense ? 8 : 14,
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: iconBg ?? AppColors.iconBg,
|
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
|
||||||
),
|
|
||||||
child: Icon(
|
|
||||||
icon,
|
|
||||||
size: 20,
|
|
||||||
color: iconColor ?? AppColors.primary,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
child: Row(
|
||||||
],
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
children: [
|
||||||
Text(
|
if (icon != null) ...[
|
||||||
title,
|
Container(
|
||||||
style: const TextStyle(
|
width: 34,
|
||||||
fontSize: 16,
|
height: 34,
|
||||||
color: AppColors.textPrimary,
|
decoration: BoxDecoration(
|
||||||
fontWeight: FontWeight.w700,
|
color: iconBg ?? AppColors.iconBg,
|
||||||
|
borderRadius: AppRadius.smBorder,
|
||||||
|
),
|
||||||
|
child: Icon(
|
||||||
|
icon,
|
||||||
|
size: 20,
|
||||||
|
color: iconColor ?? AppColors.primary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
],
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: dense ? 15 : 16,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
if (subtitle != null && subtitle!.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 13,
|
||||||
|
color: AppTheme.textSub,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (subtitle != null && subtitle!.isNotEmpty)
|
_AppSwitch(value: value, onChanged: onChanged),
|
||||||
const SizedBox(height: 2),
|
|
||||||
if (subtitle != null && subtitle!.isNotEmpty)
|
|
||||||
Text(
|
|
||||||
subtitle!,
|
|
||||||
style: TextStyle(fontSize: 13, color: AppTheme.textSub),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Switch(
|
if (showDivider)
|
||||||
value: value,
|
Padding(
|
||||||
onChanged: onChanged,
|
padding: EdgeInsets.only(left: icon != null ? 60 : 14),
|
||||||
activeThumbColor: AppTheme.primary,
|
child: const Divider(
|
||||||
activeTrackColor: AppColors.primaryLight,
|
height: 1,
|
||||||
),
|
thickness: 0.7,
|
||||||
|
color: Color(0xFFE8ECF2),
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _AppSwitch extends StatelessWidget {
|
||||||
|
final bool value;
|
||||||
|
final ValueChanged<bool> onChanged;
|
||||||
|
|
||||||
|
const _AppSwitch({required this.value, required this.onChanged});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final trackColor = value ? AppColors.primary : Colors.white;
|
||||||
|
final borderColor = value
|
||||||
|
? AppColors.primary.withValues(alpha: 0.38)
|
||||||
|
: AppColors.border;
|
||||||
|
return Semantics(
|
||||||
|
button: true,
|
||||||
|
toggled: value,
|
||||||
|
child: GestureDetector(
|
||||||
|
behavior: HitTestBehavior.opaque,
|
||||||
|
onTap: () => onChanged(!value),
|
||||||
|
child: AnimatedContainer(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
width: 46,
|
||||||
|
height: 28,
|
||||||
|
padding: const EdgeInsets.all(3),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: trackColor,
|
||||||
|
borderRadius: AppRadius.pillBorder,
|
||||||
|
border: Border.all(color: borderColor, width: 1),
|
||||||
|
),
|
||||||
|
child: AnimatedAlign(
|
||||||
|
duration: const Duration(milliseconds: 160),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
alignment: value ? Alignment.centerRight : Alignment.centerLeft,
|
||||||
|
child: Container(
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: value ? Colors.white : AppColors.textHint,
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
class _TimeButton extends StatelessWidget {
|
class _TimeButton extends StatelessWidget {
|
||||||
final String label;
|
final String label;
|
||||||
final String time;
|
final String time;
|
||||||
@@ -508,7 +565,7 @@ class _TimeButton extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: AppColors.border),
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rSm),
|
borderRadius: AppRadius.mdBorder,
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import 'app_gradient_widgets.dart';
|
||||||
|
|
||||||
class AppEmptyState extends StatelessWidget {
|
class AppEmptyState extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
@@ -32,7 +33,7 @@ class AppEmptyState extends StatelessWidget {
|
|||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 34, color: AppColors.primaryDark),
|
child: AppGradientIcon(icon: icon, size: 34),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
Text(
|
Text(
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../core/app_colors.dart';
|
import '../core/app_colors.dart';
|
||||||
|
import 'app_gradient_widgets.dart';
|
||||||
|
|
||||||
/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试",
|
/// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试",
|
||||||
/// 避免把网络/服务失败误显示成"暂无数据"。
|
/// 避免把网络/服务失败误显示成"暂无数据"。
|
||||||
@@ -32,10 +33,9 @@ class AppErrorState extends StatelessWidget {
|
|||||||
border: Border.all(color: AppColors.borderLight),
|
border: Border.all(color: AppColors.borderLight),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: AppColors.cardShadowLight,
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const AppGradientIcon(
|
||||||
Icons.cloud_off_outlined,
|
icon: Icons.cloud_off_outlined,
|
||||||
size: 34,
|
size: 34,
|
||||||
color: AppColors.primaryDark,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 18),
|
||||||
@@ -61,17 +61,9 @@ class AppErrorState extends StatelessWidget {
|
|||||||
],
|
],
|
||||||
if (onRetry != null) ...[
|
if (onRetry != null) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 20),
|
||||||
OutlinedButton.icon(
|
SizedBox(
|
||||||
onPressed: onRetry,
|
width: 116,
|
||||||
icon: const Icon(Icons.refresh, size: 18),
|
child: AppGradientOutlineButton(label: '重试', onTap: onRetry!),
|
||||||
label: const Text('重试'),
|
|
||||||
style: OutlinedButton.styleFrom(
|
|
||||||
foregroundColor: AppColors.primaryDark,
|
|
||||||
side: const BorderSide(color: AppColors.borderLight),
|
|
||||||
shape: RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
93
health_app/lib/widgets/app_gradient_widgets.dart
Normal file
93
health_app/lib/widgets/app_gradient_widgets.dart
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../core/app_colors.dart';
|
||||||
|
|
||||||
|
class AppGradientText extends StatelessWidget {
|
||||||
|
final String text;
|
||||||
|
final TextStyle style;
|
||||||
|
final Gradient gradient;
|
||||||
|
final TextAlign? textAlign;
|
||||||
|
|
||||||
|
const AppGradientText(
|
||||||
|
this.text, {
|
||||||
|
super.key,
|
||||||
|
required this.style,
|
||||||
|
this.gradient = AppColors.primaryGradient,
|
||||||
|
this.textAlign,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ShaderMask(
|
||||||
|
shaderCallback: gradient.createShader,
|
||||||
|
blendMode: BlendMode.srcIn,
|
||||||
|
child: Text(
|
||||||
|
text,
|
||||||
|
textAlign: textAlign,
|
||||||
|
style: style.copyWith(color: Colors.white),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppGradientIcon extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final double size;
|
||||||
|
final Gradient gradient;
|
||||||
|
|
||||||
|
const AppGradientIcon({
|
||||||
|
super.key,
|
||||||
|
required this.icon,
|
||||||
|
required this.size,
|
||||||
|
this.gradient = AppColors.primaryGradient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return ShaderMask(
|
||||||
|
shaderCallback: gradient.createShader,
|
||||||
|
blendMode: BlendMode.srcIn,
|
||||||
|
child: Icon(icon, size: size, color: Colors.white),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppGradientOutlineButton extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final Gradient gradient;
|
||||||
|
|
||||||
|
const AppGradientOutlineButton({
|
||||||
|
super.key,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.gradient = AppColors.primaryGradient,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return GestureDetector(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Container(
|
||||||
|
height: 50,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
gradient: gradient,
|
||||||
|
borderRadius: BorderRadius.circular(18),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.all(1.2),
|
||||||
|
child: Container(
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(17),
|
||||||
|
),
|
||||||
|
child: AppGradientText(
|
||||||
|
label,
|
||||||
|
gradient: gradient,
|
||||||
|
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -380,25 +380,25 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
icon: LucideIcons.folderHeart,
|
icon: LucideIcons.folderHeart,
|
||||||
title: '档案',
|
title: '档案',
|
||||||
route: 'healthArchive',
|
route: 'healthArchive',
|
||||||
colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)],
|
colors: AppColors.healthGradient.colors,
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: LucideIcons.fileText,
|
icon: LucideIcons.fileText,
|
||||||
title: '报告',
|
title: '报告',
|
||||||
route: 'reports',
|
route: 'reports',
|
||||||
colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)],
|
colors: AppColors.reportGradient.colors,
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: LucideIcons.pill,
|
icon: LucideIcons.pill,
|
||||||
title: '用药',
|
title: '用药',
|
||||||
route: 'medications',
|
route: 'medications',
|
||||||
colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)],
|
colors: AppColors.medicationGradient.colors,
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: LucideIcons.utensils,
|
icon: LucideIcons.utensils,
|
||||||
title: '饮食',
|
title: '饮食',
|
||||||
route: 'dietRecords',
|
route: 'dietRecords',
|
||||||
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
colors: AppColors.dietGradient.colors,
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: LucideIcons.calendarDays,
|
icon: LucideIcons.calendarDays,
|
||||||
@@ -416,13 +416,13 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
icon: LucideIcons.footprints,
|
icon: LucideIcons.footprints,
|
||||||
title: '运动',
|
title: '运动',
|
||||||
route: 'exercisePlan',
|
route: 'exercisePlan',
|
||||||
colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)],
|
colors: AppColors.exerciseGradient.colors,
|
||||||
),
|
),
|
||||||
_NavItem(
|
_NavItem(
|
||||||
icon: LucideIcons.bluetooth,
|
icon: LucideIcons.bluetooth,
|
||||||
title: '设备',
|
title: '设备',
|
||||||
route: 'devices',
|
route: 'devices',
|
||||||
colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)],
|
colors: AppColors.deviceGradient.colors,
|
||||||
),
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -466,14 +466,14 @@ class _NavTile extends StatelessWidget {
|
|||||||
};
|
};
|
||||||
|
|
||||||
List<Color> get _colors => switch (item.route) {
|
List<Color> get _colors => switch (item.route) {
|
||||||
'healthArchive' => const [AppColors.healthLight, AppColors.health],
|
'healthArchive' => AppColors.healthGradient.colors,
|
||||||
'reports' => const [AppColors.reportLight, AppColors.report],
|
'reports' => AppColors.reportGradient.colors,
|
||||||
'medications' => const [AppColors.medicationLight, AppColors.medication],
|
'medications' => AppColors.medicationGradient.colors,
|
||||||
'dietRecords' => const [AppColors.dietLight, AppColors.diet],
|
'dietRecords' => AppColors.dietGradient.colors,
|
||||||
'calendar' => const [AppColors.calendarLight, AppColors.calendar],
|
'calendar' => const [AppColors.calendarLight, AppColors.calendar],
|
||||||
'followups' => const [AppColors.followupLight, AppColors.followup],
|
'followups' => const [AppColors.followupLight, AppColors.followup],
|
||||||
'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise],
|
'exercisePlan' => AppColors.exerciseGradient.colors,
|
||||||
'devices' => const [AppColors.deviceLight, AppColors.device],
|
'devices' => AppColors.deviceGradient.colors,
|
||||||
_ => item.colors,
|
_ => item.colors,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
12
health_app/test/diet_meal_selection_test.dart
Normal file
12
health_app/test/diet_meal_selection_test.dart
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:health_app/pages/diet/diet_capture_page.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('new diet analysis requires the user to choose a meal type', () {
|
||||||
|
final container = ProviderContainer();
|
||||||
|
addTearDown(container.dispose);
|
||||||
|
|
||||||
|
expect(container.read(dietProvider).mealType, isEmpty);
|
||||||
|
});
|
||||||
|
}
|
||||||
18
health_app/test/diet_record_logic_test.dart
Normal file
18
health_app/test/diet_record_logic_test.dart
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:health_app/pages/diet/diet_record_logic.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
test('recent diet dates end with today', () {
|
||||||
|
final now = DateTime(2026, 7, 12, 21, 30);
|
||||||
|
|
||||||
|
final dates = recentDietDates(now);
|
||||||
|
|
||||||
|
expect(dates, hasLength(7));
|
||||||
|
expect(dates.first, DateTime(2026, 7, 6));
|
||||||
|
expect(dates.last, DateTime(2026, 7, 12));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('blank food draft is not valid for saving', () {
|
||||||
|
expect(isSavableFood(name: '', portion: '', calories: 0), isFalse);
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user