feat: 饮食记录逻辑重构 + 通知管理分隔线修复 + 多页面 UI 调整

- 饮食: 新增 DietCommentaryPolicy + diet_record_logic, 饮食记录逻辑抽取复用
- 通知管理: 分隔线改为仿通知中心样式, 跳过图标区域只留文字下方
- 侧边栏: 对话记录操作栏浮层修复 + 常用功能间距收紧
- 智能体: 欢迎卡片去 400ms 延迟 + 胶囊去阴影
- 后端: AI 会话仓库新增方法 + 饮食评论策略 + 健康记录规则调整
- 其他: api_client IP 适配 + 多页面 UI 微调 + 新增测试
This commit is contained in:
MingNian
2026-07-12 22:49:38 +08:00
parent d82e006cf4
commit 335a3e6440
33 changed files with 2056 additions and 1283 deletions

View File

@@ -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);
} }

View File

@@ -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();
} }

View 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 23
1222使 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;
}
}

View File

@@ -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} 不合理");
} }
} }

View File

@@ -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"),

View File

@@ -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);

View File

@@ -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);

View File

@@ -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;
} }

View 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));
}
}

View File

@@ -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()
{ {

View File

@@ -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');
}); });

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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);
} }

View File

@@ -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,
), ),

View File

@@ -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;

View File

@@ -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),
), ),

View File

@@ -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

View 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;
}

View File

@@ -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),

View File

@@ -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,

View File

@@ -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')}';
} }

View File

@@ -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;

View File

@@ -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

View File

@@ -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: [

View File

@@ -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(

View File

@@ -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),
),
),
), ),
], ],
], ],

View 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),
),
),
),
);
}
}

View File

@@ -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,
}; };

View 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);
});
}

View 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);
});
}