diff --git a/backend/src/Health.Application/AI/AiConversationContracts.cs b/backend/src/Health.Application/AI/AiConversationContracts.cs index 42bb634..9b158cf 100644 --- a/backend/src/Health.Application/AI/AiConversationContracts.cs +++ b/backend/src/Health.Application/AI/AiConversationContracts.cs @@ -48,6 +48,7 @@ public interface IAiConversationRepository Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct); Task AddConversationAsync(Conversation conversation, CancellationToken ct); Task AddMessageAsync(ConversationMessage message, CancellationToken ct); + Task DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct); void Delete(Conversation conversation); Task SaveChangesAsync(CancellationToken ct); } diff --git a/backend/src/Health.Application/AI/AiConversationService.cs b/backend/src/Health.Application/AI/AiConversationService.cs index 539bf1e..5327f55 100644 --- a/backend/src/Health.Application/AI/AiConversationService.cs +++ b/backend/src/Health.Application/AI/AiConversationService.cs @@ -55,6 +55,10 @@ public sealed class AiConversationService(IAiConversationRepository conversation public async Task> ListAsync(Guid userId, CancellationToken ct) { + await _conversations.DeleteLegacyDietCommentaryArtifactsAsync( + userId, + DietCommentaryPolicy.LegacyPromptPrefix, + ct); var conversations = await _conversations.ListAsync(userId, ct); return conversations.Take(HistoryConversationLimit).Select(ToDto).ToList(); } diff --git a/backend/src/Health.Application/AI/DietCommentaryPolicy.cs b/backend/src/Health.Application/AI/DietCommentaryPolicy.cs new file mode 100644 index 0000000..325d88a --- /dev/null +++ b/backend/src/Health.Application/AI/DietCommentaryPolicy.cs @@ -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 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 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; + } +} diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs index 5b64f4c..b499ad7 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordRules.cs @@ -9,7 +9,7 @@ public static class HealthRecordRules { HealthMetricType.BloodPressure => request.Systolic >= 140 || request.Diastolic >= 90 || request.Systolic <= 89 || request.Diastolic <= 59, 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.Weight => false, _ => false @@ -58,4 +58,3 @@ public static class HealthRecordRules throw new ValidationException($"{field}应在 {min}~{max} {unit} 之间,当前值 {value} 不合理"); } } - diff --git a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs index e881a20..9a92ea1 100644 --- a/backend/src/Health.Application/HealthRecords/HealthRecordService.cs +++ b/backend/src/Health.Application/HealthRecords/HealthRecordService.cs @@ -101,6 +101,7 @@ public sealed class HealthRecordService( latest.Diastolic, latest.Value, latest.Unit, + latest.IsAbnormal, latest.RecordedAt }; } @@ -140,7 +141,7 @@ public sealed class HealthRecordService( ("心率偏高提醒", $"本次心率为 {record.Value:0.#} 次/分,高于参考范围。建议安静休息后复测。", "warning", "heart_rate"), HealthMetricType.HeartRate => ("心率偏低提醒", $"本次心率为 {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"), HealthMetricType.Glucose => ("血糖偏低提醒", $"本次血糖为 {record.Value:0.#} mmol/L,低于参考范围。请及时关注身体状况。", "critical", "glucose"), diff --git a/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs b/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs index 8fba7a6..b9ce2a7 100644 --- a/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs +++ b/backend/src/Health.Infrastructure/AI/EfAiConversationRepository.cs @@ -37,6 +37,29 @@ public sealed class EfAiConversationRepository(AppDbContext db) : IAiConversatio public async Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => await _db.ConversationMessages.AddAsync(message, ct); + public async Task 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) => _db.Conversations.Remove(conversation); diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 5d5eca3..6d7b344 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -335,6 +335,49 @@ public static class AiChatEndpoints 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 ( HttpRequest httpRequest, HttpContext http, @@ -623,7 +666,7 @@ public static class AiChatEndpoints AddMetricPreview(preview, HealthMetricType.HeartRate, GetDecimal(args, "heart_rate"), "次/分", v => v > 100 || v < 60); break; 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; case "spo2": 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? Foods); diff --git a/backend/tests/Health.Tests/application_service_tests.cs b/backend/tests/Health.Tests/application_service_tests.cs index 76f2f35..c6275ad 100644 --- a/backend/tests/Health.Tests/application_service_tests.cs +++ b/backend/tests/Health.Tests/application_service_tests.cs @@ -223,6 +223,7 @@ public sealed class ApplicationServiceTests public Task> GetRecentMessagesAsync(Guid conversationId, int limit, CancellationToken ct) => Task.FromResult>([]); public Task AddConversationAsync(Conversation value, CancellationToken ct) => Task.CompletedTask; public Task AddMessageAsync(ConversationMessage message, CancellationToken ct) => Task.CompletedTask; + public Task DeleteLegacyDietCommentaryArtifactsAsync(Guid userId, string promptPrefix, CancellationToken ct) => Task.FromResult(0); public void Delete(Conversation value) { } public Task SaveChangesAsync(CancellationToken ct) => Task.CompletedTask; } diff --git a/backend/tests/Health.Tests/diet_commentary_tests.cs b/backend/tests/Health.Tests/diet_commentary_tests.cs new file mode 100644 index 0000000..c7cf3bd --- /dev/null +++ b/backend/tests/Health.Tests/diet_commentary_tests.cs @@ -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)); + } +} diff --git a/backend/tests/Health.Tests/persistence_pipeline_tests.cs b/backend/tests/Health.Tests/persistence_pipeline_tests.cs index c90bddb..1517359 100644 --- a/backend/tests/Health.Tests/persistence_pipeline_tests.cs +++ b/backend/tests/Health.Tests/persistence_pipeline_tests.cs @@ -1,4 +1,5 @@ using Health.Application.Medications; +using Health.Application.AI; using Health.Domain.Entities; using Health.Domain.Enums; using Health.Infrastructure.AI; @@ -13,6 +14,50 @@ namespace Health.Tests; 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] public async Task AiConfirmation_CanOnlyBeTakenOnceByOwner() { diff --git a/health_app/lib/app.dart b/health_app/lib/app.dart index b506161..9c61ab5 100644 --- a/health_app/lib/app.dart +++ b/health_app/lib/app.dart @@ -104,6 +104,7 @@ class _RootNavigator extends ConsumerWidget { final stack = ref.watch(routeStackProvider); final current = stack.last; final authState = ref.watch(authProvider); + final isPublicStaticText = current.name == 'staticText'; // 登录后自动跳转(在下一帧完成,无闪烁) if (authState.isLoggedIn && current.name == 'login') { @@ -120,7 +121,8 @@ class _RootNavigator extends ConsumerWidget { } if (!authState.isLoading && !authState.isLoggedIn && - current.name != 'login') { + current.name != 'login' && + !isPublicStaticText) { WidgetsBinding.instance.addPostFrameCallback((_) { goRoute(ref, 'login'); }); diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 22fb71b..038710e 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -6,7 +6,7 @@ import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.221.78:5000', + defaultValue: 'http://10.4.251.10:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index e2327c2..3a448a6 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -20,30 +20,30 @@ class AppColors { static const Color meadowAccent = Color(0xFFBAE6FD); static const Color sageAccent = Color(0xFFE9D5FF); - static const Color health = Color(0xFF3B82F6); - static const Color healthLight = Color(0xFFEFF6FF); - static const Color healthBorder = Color(0xFFBFDBFE); - static const Color medication = Color(0xFF8B5CF6); - static const Color medicationLight = Color(0xFFF5F3FF); - static const Color medicationBorder = Color(0xFFDDD6FE); - static const Color exercise = Color(0xFF10B981); - static const Color exerciseLight = Color(0xFFECFDF5); - static const Color exerciseBorder = Color(0xFFA7F3D0); - static const Color report = Color(0xFF2563EB); - static const Color reportLight = Color(0xFFEEF2FF); - static const Color reportBorder = Color(0xFFC7D2FE); - static const Color diet = Color(0xFFF97316); - static const Color dietLight = Color(0xFFFFF7ED); - static const Color dietBorder = Color(0xFFFED7AA); - static const Color device = Color(0xFF06B6D4); - static const Color deviceLight = Color(0xFFECFEFF); - static const Color deviceBorder = Color(0xFFA5F3FC); + static const Color health = Color(0xFF34D399); + static const Color healthLight = Color(0xFFF0FDF7); + static const Color healthBorder = Color(0xFFC8F7E1); + static const Color medication = Color(0xFF00B8D9); + static const Color medicationLight = Color(0xFFEAFBFF); + static const Color medicationBorder = Color(0xFFB8F0FA); + static const Color exercise = Color(0xFF8EC5FC); + static const Color exerciseLight = Color(0xFFF5F0FF); + static const Color exerciseBorder = Color(0xFFDCCBFE); + static const Color report = Color(0xFF6366F1); + static const Color reportLight = Color(0xFFEFFBFF); + static const Color reportBorder = Color(0xFFBAE6FD); + static const Color diet = Color(0xFFF7971E); + static const Color dietLight = Color(0xFFFFF8DB); + static const Color dietBorder = Color(0xFFFFE58A); + static const Color device = Color(0xFF2563EB); + static const Color deviceLight = Color(0xFFEFF6FF); + static const Color deviceBorder = Color(0xFFBFDBFE); static const Color notification = Color(0xFF7C5CFF); static const Color notificationLight = Color(0xFFF5F3FF); static const Color notificationBorder = Color(0xFFDDD6FE); - static const Color doctor = Color(0xFF0F766E); - static const Color doctorLight = Color(0xFFF0FDFA); - static const Color doctorBorder = Color(0xFF99F6E4); + static const Color doctor = Color(0xFFFFAFBD); + static const Color doctorLight = Color(0xFFFFF3EF); + static const Color doctorBorder = Color(0xFFFFD7CB); static const Color calendar = Color(0xFF6366F1); static const Color calendarLight = Color(0xFFEEF2FF); static const Color calendarBorder = Color(0xFFC7D2FE); @@ -157,45 +157,45 @@ class AppColors { ); static const LinearGradient doctorGradient = LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFFFFC3A0), Color(0xFFFFAFBD)], ); static const LinearGradient healthGradient = LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF2563EB)], + begin: Alignment.centerLeft, + end: Alignment.centerRight, + colors: [Color(0xFF6EE7B7), Color(0xFF67E8F9)], ); static const LinearGradient medicationGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFA78BFA), Color(0xFF7C3AED)], + colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)], ); static const LinearGradient exerciseGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF34D399), Color(0xFF059669)], + colors: [Color(0xFFE0C3FC), Color(0xFF8EC5FC)], ); static const LinearGradient reportGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF60A5FA), Color(0xFF2563EB)], + colors: [Color(0xFF38BDF8), Color(0xFF818CF8)], ); static const LinearGradient dietGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFFB923C), Color(0xFFF97316)], + colors: [Color(0xFFF7971E), Color(0xFFFFD200)], ); static const LinearGradient deviceGradient = LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFF22D3EE), Color(0xFF0891B2)], + colors: [Color(0xFF60A5FA), Color(0xFF2563EB)], ); static const LinearGradient notificationGradient = LinearGradient( diff --git a/health_app/lib/core/navigation_provider.dart b/health_app/lib/core/navigation_provider.dart index e49388b..e266fa0 100644 --- a/health_app/lib/core/navigation_provider.dart +++ b/health_app/lib/core/navigation_provider.dart @@ -32,7 +32,9 @@ class RouteStackNotifier extends Notifier> { /// 路由栈 Provider final routeStackProvider = - NotifierProvider>(RouteStackNotifier.new); + NotifierProvider>( + RouteStackNotifier.new, + ); /// 当前路由 final currentRouteProvider = Provider((ref) { @@ -46,13 +48,21 @@ void _dismissKeyboard() { } /// 跳转(替换整个栈) -void goRoute(WidgetRef ref, String name, {Map params = const {}}) { +void goRoute( + WidgetRef ref, + String name, { + Map params = const {}, +}) { _dismissKeyboard(); ref.read(routeStackProvider.notifier).replace(name, params: params); } /// 推入新页面 -void pushRoute(WidgetRef ref, String name, {Map params = const {}}) { +void pushRoute( + WidgetRef ref, + String name, { + Map params = const {}, +}) { _dismissKeyboard(); ref.read(routeStackProvider.notifier).push(name, params: params); } diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index 29f821e..a5f8120 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -4,6 +4,7 @@ import '../../core/app_colors.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/data_providers.dart'; +import '../../widgets/app_gradient_widgets.dart'; class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); @@ -13,6 +14,11 @@ class LoginPage extends ConsumerStatefulWidget { class _LoginPageState extends ConsumerState { 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 _codeCtrl = TextEditingController(); @@ -174,23 +180,51 @@ class _LoginPageState extends ConsumerState { const SizedBox(height: 18), RichText( textAlign: TextAlign.center, - text: const TextSpan( - style: TextStyle( + text: TextSpan( + style: const TextStyle( fontSize: 15, height: 1.55, fontWeight: FontWeight.w700, color: AppColors.textSecondary, ), children: [ - TextSpan(text: '请阅读并同意'), - TextSpan( - text: '《服务协议》', - style: TextStyle(color: AppColors.primary), + const TextSpan(text: '请阅读并同意'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + 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: '和'), - TextSpan( - text: '《隐私政策》', - style: TextStyle(color: AppColors.primary), + const TextSpan(text: '和'), + WidgetSpan( + alignment: PlaceholderAlignment.baseline, + 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 { Row( children: [ Expanded( - child: SizedBox( - height: 50, - child: OutlinedButton( - 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('不同意'), - ), + child: AppGradientOutlineButton( + label: '不同意', + onTap: () => Navigator.pop(ctx, false), ), ), const SizedBox(width: 12), @@ -229,7 +246,7 @@ class _LoginPageState extends ConsumerState { height: 50, alignment: Alignment.center, decoration: BoxDecoration( - gradient: AppColors.doctorGradient, + gradient: _loginActionGradient, borderRadius: BorderRadius.circular(18), boxShadow: AppColors.buttonShadow, ), @@ -328,9 +345,9 @@ class _LoginPageState extends ConsumerState { ), ), trailing: _selectedDoctorId == d['id']?.toString() - ? const Icon( - Icons.check_circle, - color: AppColors.primary, + ? const AppGradientIcon( + icon: Icons.check_circle, + size: 22, ) : null, onTap: () { @@ -368,6 +385,7 @@ class _LoginPageState extends ConsumerState { } return Scaffold( + resizeToAvoidBottomInset: false, backgroundColor: Colors.white, body: Stack( children: [ @@ -475,6 +493,9 @@ class _LoginPageState extends ConsumerState { agreed: _agreed, onTap: () => setState(() => _agreed = !_agreed), + onTermsTap: () => _openStaticText('terms'), + onPrivacyTap: () => + _openStaticText('privacy'), ), if (_error != null) ...[ const SizedBox(height: 12), @@ -496,13 +517,13 @@ class _LoginPageState extends ConsumerState { _error = null; _successMsg = null; }), - child: Text( - _isLogin ? '没有账号?去注册' : '已有账号?去登录', - textAlign: TextAlign.center, - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w800, - color: AppColors.primary, + child: Center( + child: AppGradientText( + _isLogin ? '没有账号?去注册' : '已有账号?去登录', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + ), ), ), ), @@ -645,7 +666,7 @@ class _TextField extends StatelessWidget { ), focusedBorder: OutlineInputBorder( 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 Widget build(BuildContext context) { - return ShaderMask( - shaderCallback: AppColors.doctorGradient.createShader, - blendMode: BlendMode.srcIn, - child: Icon(icon, size: 23, color: Colors.white), + return AppGradientIcon( + icon: icon, + size: 23, + gradient: _LoginPageState._loginActionGradient, ); } } @@ -730,7 +751,7 @@ class _SmsButton extends StatelessWidget { height: 50, alignment: Alignment.center, decoration: BoxDecoration( - gradient: disabled ? null : AppColors.doctorGradient, + gradient: disabled ? null : _LoginPageState._loginActionGradient, color: disabled ? AppColors.cardInner : null, borderRadius: BorderRadius.circular(14), border: Border.all( @@ -757,64 +778,67 @@ class _SmsButton extends StatelessWidget { class _Agreement extends StatelessWidget { final bool agreed; 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 Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( + const textStyle = TextStyle(fontSize: 13, color: AppColors.textHint); + const linkStyle = TextStyle(fontSize: 13, fontWeight: FontWeight.w700); + + return Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( width: 18, height: 18, - margin: const EdgeInsets.only(right: 7, top: 1), + margin: const EdgeInsets.only(right: 7), decoration: BoxDecoration( - color: agreed ? AppColors.primary : Colors.white, + gradient: agreed ? _LoginPageState._loginActionGradient : null, + color: agreed ? null : Colors.white, borderRadius: BorderRadius.circular(5), border: Border.all( - color: agreed ? AppColors.primary : AppColors.border, + color: agreed ? Colors.transparent : AppColors.border, ), ), child: agreed ? const Icon(Icons.check, size: 13, color: Colors.white) : null, ), - Flexible( - child: RichText( - text: const TextSpan( - children: [ - TextSpan( - text: '已阅读并同意', - style: TextStyle(fontSize: 12, color: AppColors.textHint), - ), - TextSpan( - 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, - ), - ), - ], + ), + Expanded( + child: Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: const Text('已阅读并同意', style: textStyle), ), - ), + 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, alignment: Alignment.center, decoration: BoxDecoration( - gradient: AppColors.doctorGradient, + gradient: _LoginPageState._loginActionGradient, borderRadius: BorderRadius.circular(14), boxShadow: AppColors.buttonShadow, ), diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 4075094..c121890 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -814,6 +814,7 @@ class _TrendPageState extends ConsumerState { _filtered.removeWhere((x) => x['id']?.toString() == id); _selectedIdx = null; }); + ref.invalidate(latestHealthProvider); return true; } catch (_) { return false; diff --git a/health_app/lib/pages/device/device_management_page.dart b/health_app/lib/pages/device/device_management_page.dart index 65b32c9..1bda296 100644 --- a/health_app/lib/pages/device/device_management_page.dart +++ b/health_app/lib/pages/device/device_management_page.dart @@ -685,12 +685,12 @@ class _EmptyDevices extends StatelessWidget { width: 72, height: 72, decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: AppColors.deviceLight, borderRadius: BorderRadius.circular(22), ), child: const Icon( Icons.bluetooth_disabled_rounded, - color: Color(0xFF2563EB), + color: AppColors.device, size: 36, ), ), @@ -747,7 +747,7 @@ class _ScanIndicator extends StatelessWidget { height: 78 * animation.value, decoration: BoxDecoration( 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, decoration: BoxDecoration( 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, height: 58, decoration: BoxDecoration( - color: const Color(0xFF2563EB), + color: AppColors.device, shape: BoxShape.circle, boxShadow: [ BoxShadow( - color: const Color(0xFF2563EB).withValues(alpha: 0.24), + color: AppColors.device.withValues(alpha: 0.24), blurRadius: 18, offset: const Offset(0, 8), ), diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index f589316..666958e 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -439,15 +439,13 @@ class _DeviceResultTile extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: AppColors.primary.withValues(alpha: 0.10), + color: AppColors.deviceLight, borderRadius: BorderRadius.circular(14), - border: Border.all( - color: AppColors.primary.withValues(alpha: 0.10), - ), + border: Border.all(color: AppColors.deviceBorder), ), child: Icon( type?.icon ?? Icons.bluetooth_rounded, - color: AppColors.primary, + color: AppColors.device, size: 25, ), ), @@ -483,6 +481,10 @@ class _DeviceResultTile extends StatelessWidget { child: FilledButton( onPressed: connecting ? null : onConnect, style: FilledButton.styleFrom( + backgroundColor: AppColors.device, + foregroundColor: Colors.white, + disabledBackgroundColor: AppColors.cardInner, + disabledForegroundColor: AppColors.textHint, padding: const EdgeInsets.symmetric(horizontal: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), @@ -526,7 +528,7 @@ class _ScanPulse extends StatelessWidget { height: 78 * animation.value, decoration: BoxDecoration( 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( Icons.bluetooth_searching_rounded, size: 32, - color: AppColors.primary, + color: AppColors.device, ), ), ], diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 9ce82bd..c944ef8 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -5,11 +5,11 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../core/app_colors.dart'; -import '../../core/app_module_visuals.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; -import '../../utils/sse_handler.dart'; import '../../widgets/app_toast.dart'; +import 'diet_record_logic.dart'; final dietProvider = NotifierProvider( DietNotifier.new, @@ -71,6 +71,11 @@ class FoodItem { }); } +class DietFoodValidationException implements Exception { + final String message; + const DietFoodValidationException(this.message); +} + class DietNotifier extends Notifier { @override DietState build() => DietState(); @@ -80,7 +85,11 @@ class DietNotifier extends Notifier { } Future analyzeImage() async { - state = state.copyWith(isAnalyzing: true, errorMessage: null); + state = DietState( + imagePath: state.imagePath, + mealType: state.mealType, + isAnalyzing: true, + ); try { final api = ref.read(apiClientProvider); final path = state.imagePath!; @@ -119,25 +128,23 @@ class DietNotifier extends Notifier { Future _fetchCommentary(List foods) async { try { final api = ref.read(apiClientProvider); - final token = await api.accessToken; - if (token == null) return; - final names = foods - .map((f) => '${f.name}(${f.portion},${f.calories}kcal)') - .join('、'); - final stream = SseHandler.connect( - agentType: 'default', - message: '我刚才吃了这些:$names。请结合我的健康档案,给我简短的饮食评价和建议(50字以内)。', - token: token, + final response = await api.post( + '/api/ai/diet-commentary', + data: { + 'foods': foods + .map( + (food) => { + 'name': food.name, + 'portion': food.portion, + 'calories': food.calories, + }, + ) + .toList(), + }, ); - String text = ''; - await for (final event in stream) { - if (event['action'] == 'answer') { - text += (event['data'] as String?) ?? ''; - } - if (event['action'] == 'status') { - if (text.isNotEmpty) state = state.copyWith(commentary: text.trim()); - } - } + final text = + response.data['data']?['commentary']?.toString().trim() ?? ''; + if (text.isNotEmpty) state = state.copyWith(commentary: text); } catch (e) { debugPrint('[Diet] 获取饮食点评失败: $e'); } @@ -250,17 +257,11 @@ class DietNotifier extends Notifier { state = state.copyWith( foods: [ ...state.foods, - FoodItem(id: newId, name: '新食物', portion: '', calories: 100), + FoodItem(id: newId, name: '', portion: '', calories: 0), ], ); } - void removeFood(String id) { - state = state.copyWith( - foods: state.foods.where((f) => f.id != id).toList(), - ); - } - void setMealType(String type) { state = state.copyWith(mealType: type); } @@ -271,7 +272,19 @@ class DietNotifier extends Notifier { Future saveRecord() async { final selectedFoods = state.foods.where((f) => f.selected).toList(); - if (selectedFoods.isEmpty) return; + if (selectedFoods.isEmpty) { + throw const DietFoodValidationException('请至少选择一种食物'); + } + final hasIncompleteFood = selectedFoods.any( + (food) => !isSavableFood( + name: food.name, + portion: food.portion, + calories: food.calories, + ), + ); + if (hasIncompleteFood) { + throw const DietFoodValidationException('请完善已选食物的名称、份量和热量'); + } final api = ref.read(apiClientProvider); final mealMap = { 'breakfast': 'Breakfast', @@ -305,9 +318,7 @@ class DietNotifier extends Notifier { } // ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── -const _dietVisual = AppModuleVisuals.diet; const _dietAccent = AppColors.diet; -const _dietAccentLight = AppColors.dietLight; const _dietKcalText = Color(0xFF9A3412); const _dietGradient = AppColors.dietGradient; @@ -352,114 +363,115 @@ class _DietCapturePageState extends ConsumerState { ), ) : _buildResultView(context, ref), + bottomNavigationBar: !state.isAnalyzing && state.foods.isNotEmpty + ? _buildBottomActions() + : null, ); } Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); - final screenW = MediaQuery.sizeOf(context).width; return SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - // 图片自适应显示 - Container( - padding: const EdgeInsets.all(8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(24), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(18), - child: Image.file( - File(state.imagePath!), - width: screenW - 48, - height: 220, - fit: BoxFit.cover, - ), - ), - ), - const SizedBox(height: 16), - _buildMealSelector(ref), - const SizedBox(height: 16), + _buildPhotoPreview(state), + const SizedBox(height: 14), if (state.isAnalyzing) _buildAnalyzing(state) else if (state.foods.isEmpty) _buildNoFoodHint() else ...[ + _buildMealSummary(ref), + const SizedBox(height: 14), _buildFoodList(ref), - const SizedBox(height: 16), - _buildNutritionCard(ref), - if (state.commentary != null && state.commentary!.isNotEmpty) ...[ - const SizedBox(height: 16), - _buildAiCommentary(state.commentary!), - ], - const SizedBox(height: 20), - _buildSaveButton(), + const SizedBox(height: 14), + _buildAiCommentary(state.commentary), ], ], ), ); } - // ─────────── 餐次选择器 ─────────── - Widget _buildMealSelector(WidgetRef ref) { + Widget _buildPhotoPreview(DietState state) { + return ClipRRect( + borderRadius: AppRadius.mdBorder, + child: Image.file( + File(state.imagePath!), + width: double.infinity, + height: 190, + fit: BoxFit.cover, + ), + ); + } + + Widget _buildMealSummary(WidgetRef ref) { final state = ref.watch(dietProvider); - final meals = [ - ('🌅', '早餐', 'breakfast'), - ('☀️', '午餐', 'lunch'), - ('🌙', '晚餐', 'dinner'), - ('🍪', '加餐', 'snack'), - ]; + final totalCal = state.foods + .where((f) => f.selected) + .fold(0, (s, f) => s + f.calories); + final protein = (totalCal * 0.16 / 4).round(); + final carbs = (totalCal * 0.52 / 4).round(); + final fat = (totalCal * 0.28 / 9).round(); + return Container( - padding: const EdgeInsets.all(6), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 15), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.92), - borderRadius: BorderRadius.circular(20), - border: Border.all(color: Colors.white, width: 1.4), + color: Colors.white, + borderRadius: AppRadius.xlBorder, boxShadow: AppColors.cardShadowLight, ), - child: Row( - children: meals.map((m) { - final sel = state.mealType == m.$3; - return Expanded( - child: GestureDetector( - onTap: () => ref.read(dietProvider.notifier).setMealType(m.$3), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - curve: Curves.easeOutCubic, - margin: const EdgeInsets.symmetric(horizontal: 2), - padding: const EdgeInsets.symmetric(vertical: 11), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Container( + width: 30, + height: 30, decoration: BoxDecoration( - gradient: sel ? _dietGradient : null, - color: sel ? null : const Color(0xFFFFFBF6), - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: sel ? Colors.transparent : const Color(0xFFFFE4CA), - ), + gradient: _dietGradient, + borderRadius: AppRadius.smBorder, ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox.shrink(), - const SizedBox(height: 0), - Text( - m.$2, - style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w900, - color: sel ? Colors.white : _dietKcalText, - ), - ), - ], + child: const Icon( + Icons.local_fire_department_rounded, + size: 18, + color: Colors.white, ), ), - ), - ); - }).toList(), + const SizedBox(width: 10), + const Text( + '本餐估算', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + _MealTypeMenu( + value: state.mealType, + onChanged: (type) => + ref.read(dietProvider.notifier).setMealType(type), + ), + ], + ), + const SizedBox(height: 13), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _MacroStat(label: '总热量', value: '$totalCal', unit: '千卡'), + const SizedBox(width: 8), + _MacroStat(label: '蛋白质', value: '$protein', unit: 'g'), + const SizedBox(width: 8), + _MacroStat(label: '碳水', value: '$carbs', unit: 'g'), + const SizedBox(width: 8), + _MacroStat(label: '脂肪', value: '$fat', unit: 'g'), + ], + ), + ], ), ); } @@ -536,64 +548,35 @@ class _DietCapturePageState extends ConsumerState { // ─────────── 食物列表 ─────────── Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); - final totalCal = state.foods - .where((f) => f.selected) - .fold(0, (s, f) => s + f.calories); return Container( - padding: const EdgeInsets.all(14), + clipBehavior: Clip.antiAlias, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), + borderRadius: AppRadius.xlBorder, boxShadow: AppColors.cardShadowLight, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( - children: [ - Container( - width: 4, - height: 16, - decoration: BoxDecoration( - color: _dietAccent, - borderRadius: BorderRadius.circular(2), - ), + const Padding( + padding: EdgeInsets.fromLTRB(14, 13, 14, 8), + child: Text( + '识别结果', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, ), - const SizedBox(width: 8), - const Text( - '识别结果', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 4, - ), - decoration: BoxDecoration( - color: const Color(0xFFFFF3D8), - borderRadius: BorderRadius.circular(8), - ), - child: Text( - '共 $totalCal kcal', - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: _dietKcalText, - ), - ), - ), - ], + ), ), - const SizedBox(height: 10), - ...state.foods.map((food) => _foodItemTile(ref, food)), - const SizedBox(height: 4), - Center( + for (var i = 0; i < state.foods.length; i++) + _foodItemTile( + ref, + state.foods[i], + showDivider: i < state.foods.length - 1, + ), + Align( + alignment: Alignment.centerLeft, child: TextButton.icon( onPressed: () => ref.read(dietProvider.notifier).addFood(), icon: const Icon( @@ -612,133 +595,118 @@ class _DietCapturePageState extends ConsumerState { ); } - Widget _foodItemTile(WidgetRef ref, FoodItem food) { - return Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - color: AppColors.background, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: food.selected - ? _dietAccent.withAlpha(60) - : AppColors.borderLight, - ), - ), - child: Row( - children: [ - GestureDetector( - onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: food.selected ? _dietAccent : Colors.white, - border: Border.all( - color: food.selected ? _dietAccent : AppColors.border, - width: 2, + Widget _foodItemTile( + WidgetRef ref, + FoodItem food, { + required bool showDivider, + }) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(14, 11, 12, 11), + child: Row( + children: [ + GestureDetector( + onTap: () => + ref.read(dietProvider.notifier).toggleFood(food.id), + child: Container( + width: 26, + height: 26, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: food.selected ? _dietGradient : null, + color: food.selected ? null : AppColors.dietLight, + borderRadius: AppRadius.smBorder, + border: Border.all( + color: food.selected + ? Colors.transparent + : AppColors.dietBorder, + ), + ), + child: food.selected + ? const Icon(Icons.check, size: 15, color: Colors.white) + : null, ), ), - child: food.selected - ? const Icon(Icons.check, size: 14, color: Colors.white) - : null, - ), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _compactField( + const SizedBox(width: 12), + Expanded( + flex: 4, + child: _plainField( food.name, (v) => ref .read(dietProvider.notifier) .updateFoodName(food.id, v), fieldKey: '${food.id}_name', style: const TextStyle( - fontSize: 17, + fontSize: 14, fontWeight: FontWeight.w600, + color: AppColors.textPrimary, ), ), - const SizedBox(height: 4), - Row( + ), + const SizedBox(width: 8), + Expanded( + flex: 3, + child: _plainField( + food.portion, + (v) => ref + .read(dietProvider.notifier) + .updateFoodPortion(food.id, v), + fieldKey: '${food.id}_portion', + hint: '份量', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + const SizedBox(width: 8), + SizedBox( + width: 88, + child: Row( children: [ Expanded( - flex: 3, - child: _compactField( - food.portion, + child: _plainField( + food.calories.toString(), (v) => ref .read(dietProvider.notifier) - .updateFoodPortion(food.id, v), - fieldKey: '${food.id}_portion', - hint: '份量', + .updateFoodCalories(food.id, int.tryParse(v) ?? 0), + fieldKey: '${food.id}_cal', + hint: '0', + align: TextAlign.right, + keyboardType: TextInputType.number, style: const TextStyle( fontSize: 14, - color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + color: _dietKcalText, ), ), ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: Row( - children: [ - Expanded( - child: _compactField( - food.calories.toString(), - (v) => ref - .read(dietProvider.notifier) - .updateFoodCalories( - food.id, - int.tryParse(v) ?? 0, - ), - fieldKey: '${food.id}_cal', - hint: '0', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - color: _dietKcalText, - ), - align: TextAlign.right, - keyboardType: TextInputType.number, - ), - ), - const SizedBox(width: 2), - const Text( - 'kcal', - style: TextStyle( - fontSize: 13, - color: Color(0xFFB45309), - ), - ), - ], + const Text( + ' 千卡', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppColors.textHint, ), ), ], ), - ], - ), + ), + ], ), - GestureDetector( - onTap: () => ref.read(dietProvider.notifier).removeFood(food.id), - child: const Icon(Icons.close, size: 18, color: AppColors.textHint), + ), + if (showDivider) + const Padding( + padding: EdgeInsets.only(left: 52), + child: Divider(height: 1, thickness: 0.7, color: Color(0xFFE8ECF2)), ), - ], - ), + ], ); } - final Map _fieldCtrls = {}; - - TextEditingController _ctrlFor(String key, String value) { - if (_fieldCtrls.containsKey(key)) return _fieldCtrls[key]!; - final c = TextEditingController(text: value); - _fieldCtrls[key] = c; - return c; - } - - Widget _compactField( + Widget _plainField( String value, ValueChanged cb, { required String fieldKey, @@ -755,253 +723,269 @@ class _DietCapturePageState extends ConsumerState { style: style ?? const TextStyle(fontSize: 15), decoration: InputDecoration( isDense: true, - contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - filled: true, - fillColor: Colors.white, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: AppColors.borderLight), - ), - focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(8), - borderSide: const BorderSide(color: _dietAccent), - ), + contentPadding: EdgeInsets.zero, + border: InputBorder.none, hintText: hint, - hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint), + hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), ), ); } - // ─────────── 营养摘要 ─────────── - Widget _buildNutritionCard(WidgetRef ref) { - final state = ref.watch(dietProvider); - final totalCal = state.foods - .where((f) => f.selected) - .fold(0, (s, f) => s + f.calories); + final Map _fieldCtrls = {}; + + TextEditingController _ctrlFor(String key, String value) { + if (_fieldCtrls.containsKey(key)) return _fieldCtrls[key]!; + final c = TextEditingController(text: value); + _fieldCtrls[key] = c; + return c; + } + + // ─────────── AI 点评 ─────────── + Widget _buildAiCommentary(String? text) { + final hasText = text != null && text.trim().isNotEmpty; return Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.fromLTRB(14, 13, 14, 14), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), + borderRadius: AppRadius.xlBorder, boxShadow: AppColors.cardShadowLight, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '本餐热量', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 14), - Row( - children: [ - SizedBox( - width: 56, - height: 56, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 56, - height: 56, - child: CircularProgressIndicator( - value: (totalCal / 700).clamp(0.0, 1.0), - strokeWidth: 4, - backgroundColor: AppColors.borderLight, - color: _dietAccent, - ), - ), - Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '$totalCal', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: _dietAccent, - ), - ), - const Text( - 'kcal', - style: TextStyle( - fontSize: 12, - color: AppColors.textSecondary, - ), - ), - ], - ), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - child: Row( - children: [ - _macro('碳水', 0.55, const Color(0xFFF5A623)), - const SizedBox(width: 8), - _macro('蛋白', 0.25, const Color(0xFF4A90D9)), - const SizedBox(width: 8), - _macro('脂肪', 0.20, const Color(0xFFE8686A)), - ], - ), - ), - ], - ), - ], - ), - ); - } - - Widget _macro(String label, double ratio, Color color) { - return Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( - width: 6, - height: 6, - decoration: BoxDecoration(color: color, shape: BoxShape.circle), + width: 4, + height: 18, + decoration: BoxDecoration( + gradient: _dietGradient, + borderRadius: BorderRadius.circular(999), + ), ), - const SizedBox(width: 4), - Text( - label, - style: const TextStyle( - fontSize: 12, - color: AppColors.textSecondary, + const SizedBox(width: 9), + const Text( + 'AI饮食建议', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, ), ), ], ), - const SizedBox(height: 3), - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator( - value: ratio, - minHeight: 4, - backgroundColor: AppColors.borderLight, - color: color, - ), - ), - ], - ), - ); - } - - // ─────────── AI 点评 ─────────── - Widget _buildAiCommentary(String text) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFFFBF5), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'AI建议', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), const SizedBox(height: 12), - Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: _dietAccentLight, - borderRadius: BorderRadius.circular(10), - ), - child: Icon(_dietVisual.icon, size: 20, color: _dietAccent), - ), - const SizedBox(width: 12), - Expanded( - child: Text( - text, - style: const TextStyle( - fontSize: 16, - color: AppColors.textPrimary, - height: 1.6, - ), - ), - ), - ], + Text( + hasText ? text.trim() : 'AI饮食建议生成中', + style: TextStyle( + fontSize: 15, + color: hasText ? AppColors.textPrimary : AppColors.textHint, + height: 1.55, + fontWeight: FontWeight.w600, + ), ), ], ), ); } - // ─────────── 保存按钮 ─────────── - Widget _buildSaveButton() { - return Container( - width: double.infinity, - height: 52, + Widget _buildBottomActions() { + return DecoratedBox( decoration: BoxDecoration( - gradient: _dietGradient, - borderRadius: BorderRadius.circular(16), + color: Colors.white.withValues(alpha: 0.96), boxShadow: [ BoxShadow( - color: _dietAccent.withValues(alpha: 0.24), + color: const Color(0xFF101828).withValues(alpha: 0.08), blurRadius: 16, - offset: const Offset(0, 8), + offset: const Offset(0, -6), ), ], ), - child: Material( - color: Colors.transparent, - child: InkWell( - borderRadius: BorderRadius.circular(16), - onTap: () async { - try { - await ref.read(dietProvider.notifier).saveRecord(); - if (mounted) popRoute(ref); - } catch (e) { - debugPrint('[Diet] 保存记录失败: $e'); - if (mounted) { - AppToast.show( - context, - '保存失败,请检查网络后重试', - type: AppToastType.error, - ); - } - } - }, - child: const Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.check_circle_outline, size: 22, color: Colors.white), - SizedBox(width: 8), - Text( + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 16, 12), + child: GestureDetector( + onTap: _saveDietRecord, + child: Container( + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration( + gradient: _dietGradient, + borderRadius: AppRadius.mdBorder, + boxShadow: [ + BoxShadow( + color: _dietAccent.withValues(alpha: 0.24), + blurRadius: 14, + offset: const Offset(0, 7), + ), + ], + ), + child: const Text( '保存记录', style: TextStyle( - fontSize: 17, - fontWeight: FontWeight.w800, + fontSize: 16, + fontWeight: FontWeight.w900, color: Colors.white, ), ), - ], + ), ), ), ), ); } + + Future _saveDietRecord() async { + try { + await ref.read(dietProvider.notifier).saveRecord(); + if (mounted) popRoute(ref); + } on DietFoodValidationException catch (e) { + if (mounted) { + AppToast.show(context, e.message, type: AppToastType.warning); + } + } catch (e) { + debugPrint('[Diet] 保存记录失败: $e'); + if (mounted) { + AppToast.show(context, '保存失败,请检查网络后重试', type: AppToastType.error); + } + } + } +} + +class _MealTypeMenu extends StatelessWidget { + final String value; + final ValueChanged onChanged; + + const _MealTypeMenu({required this.value, required this.onChanged}); + + static const _options = [ + (label: '早餐', value: 'breakfast'), + (label: '午餐', value: 'lunch'), + (label: '晚餐', value: 'dinner'), + (label: '加餐', value: 'snack'), + ]; + + String get _label { + for (final option in _options) { + if (option.value == value) return option.label; + } + return '午餐'; + } + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + initialValue: value, + onSelected: onChanged, + color: Colors.white, + elevation: 8, + shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorder), + itemBuilder: (context) => [ + for (final option in _options) + PopupMenuItem( + value: option.value, + child: Text( + option.label, + style: TextStyle( + fontSize: 15, + fontWeight: option.value == value + ? FontWeight.w900 + : FontWeight.w700, + color: option.value == value + ? _dietKcalText + : AppColors.textPrimary, + ), + ), + ), + ], + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), + decoration: BoxDecoration( + color: AppColors.dietLight, + borderRadius: AppRadius.mdBorder, + border: Border.all(color: AppColors.dietBorder), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + _label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w900, + color: _dietKcalText, + ), + ), + const SizedBox(width: 5), + const Icon( + Icons.keyboard_arrow_down_rounded, + size: 18, + color: _dietKcalText, + ), + ], + ), + ), + ); + } +} + +class _MacroStat extends StatelessWidget { + final String label; + final String value; + final String unit; + + const _MacroStat({ + required this.label, + required this.value, + required this.unit, + }); + + @override + Widget build(BuildContext context) { + return Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 3), + FittedBox( + fit: BoxFit.scaleDown, + alignment: Alignment.centerLeft, + child: Row( + children: [ + Text( + value, + style: const TextStyle( + fontSize: 16, + height: 1, + fontWeight: FontWeight.w900, + color: _dietKcalText, + ), + ), + const SizedBox(width: 2), + Text( + unit, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.w800, + color: AppColors.textHint, + ), + ), + ], + ), + ), + ], + ), + ); + } } diff --git a/health_app/lib/pages/diet/diet_record_logic.dart b/health_app/lib/pages/diet/diet_record_logic.dart new file mode 100644 index 0000000..bf90641 --- /dev/null +++ b/health_app/lib/pages/diet/diet_record_logic.dart @@ -0,0 +1,15 @@ +List 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; +} diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index fa82ad9..00417dd 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -263,10 +263,10 @@ class ChatMessagesView extends ConsumerWidget { const SizedBox(height: 9), Text( info.$2, - style: AppTextStyles.chatTitle.copyWith( - fontSize: 22, - fontWeight: FontWeight.w900, - ), + style: AppTextStyles.chatTitle.copyWith( + fontSize: 22, + fontWeight: FontWeight.w900, + ), ), const SizedBox(height: 8), Text( @@ -473,6 +473,11 @@ class ChatMessagesView extends ConsumerWidget { backendType != 'exercise'); final isExercise = backendType == 'exercise'; final isHealth = !isMedication && !isExercise; + final visual = isMedication + ? AppModuleVisuals.medication + : isExercise + ? AppModuleVisuals.exercise + : AppModuleVisuals.health; // 根据类型获取显示字段 String title = '数据确认'; @@ -585,7 +590,7 @@ class ChatMessagesView extends ConsumerWidget { borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( - color: const Color(0xFF6366F1).withValues(alpha: 0.10), + color: visual.color.withValues(alpha: 0.10), blurRadius: 28, offset: const Offset(0, 14), ), @@ -604,11 +609,11 @@ class ChatMessagesView extends ConsumerWidget { Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), - decoration: const BoxDecoration( + decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, - colors: [Color(0xFFFFFFFF), Color(0xFFF7F4FF)], + colors: [Colors.white, visual.lightColor], ), ), child: Row( @@ -618,11 +623,11 @@ class ChatMessagesView extends ConsumerWidget { width: 56, height: 56, decoration: BoxDecoration( - gradient: AppColors.actionOutlineGradient, + gradient: visual.gradient, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow( - color: AppColors.auraIndigo.withValues(alpha: 0.18), + color: visual.color.withValues(alpha: 0.18), blurRadius: 10, offset: const Offset(0, 4), ), @@ -674,7 +679,7 @@ class ChatMessagesView extends ConsumerWidget { child: Container( padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - gradient: AppColors.actionOutlineGradient, + gradient: visual.gradient, borderRadius: BorderRadius.circular(20), ), child: Container( @@ -691,7 +696,7 @@ class ChatMessagesView extends ConsumerWidget { width: 68, height: 68, decoration: BoxDecoration( - gradient: AppColors.lightGradient, + color: visual.lightColor, borderRadius: BorderRadius.circular(18), ), child: Center( @@ -699,14 +704,14 @@ class ChatMessagesView extends ConsumerWidget { ? Icon( _getMetricIconData(backendType), size: 34, - color: AppColors.primaryDark, + color: visual.color, ) : Icon( isMedication ? Icons.medication_liquid_outlined : LucideIcons.footprints, size: 36, - color: AppColors.iconColor, + color: visual.color, ), ), ), @@ -745,7 +750,7 @@ class ChatMessagesView extends ConsumerWidget { fontSize: 19, color: abnormal ? AppColors.error - : AppColors.iconColor, + : visual.color, fontWeight: FontWeight.w500, ), ), @@ -849,11 +854,11 @@ class ChatMessagesView extends ConsumerWidget { height: 56, padding: const EdgeInsets.all(1.4), decoration: BoxDecoration( - gradient: AppColors.actionOutlineGradient, + gradient: visual.gradient, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: AppColors.auraIndigo.withValues(alpha: 0.18), + color: visual.color.withValues(alpha: 0.18), blurRadius: 18, offset: const Offset(0, 8), ), @@ -913,7 +918,7 @@ class ChatMessagesView extends ConsumerWidget { width: 32, height: 32, decoration: BoxDecoration( - gradient: AppColors.actionOutlineGradient, + gradient: visual.gradient, borderRadius: BorderRadius.circular(10), ), child: const Icon( @@ -1268,7 +1273,6 @@ class ChatMessagesView extends ConsumerWidget { return path; } - /// 处理 AI 回复里的 markdown 链接点击: /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程 /// - app://report → 跳到报告列表(用户可在那里上传新报告) @@ -1504,27 +1508,58 @@ class ChatMessagesView extends ConsumerWidget { wtText == null; // ── 多指标异常检测 ── + // 优先使用后端保存时计算出的 IsAbnormal,兜底规则必须和后端 HealthRecordRules 保持一致。 final abnormals = []; - if (bp is Map && bp['systolic'] is int) { - final s = bp['systolic'] as int; - final d = bp['diastolic'] is int ? bp['diastolic'] as int : 0; - if (s >= 140 || d >= 90) abnormals.add('血压 $s/$d 偏高'); + final bpAbnormal = _recordIsAbnormal( + bp, + fallback: () { + 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 v = (hr['value'] as num).toDouble(); - if (v > 100) { - abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快'); - } else if (v < 60) { - abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢'); - } + final hrAbnormal = _recordIsAbnormal( + hr, + fallback: () { + final v = _numValue(hr is Map ? hr['value'] ?? hr['Value'] : null); + return v != null && (v > 100 || v < 60); + }, + ); + 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 v = (bs['value'] as num).toDouble(); - if (v > 6.1) abnormals.add('血糖 ${v.toStringAsFixed(1)} 偏高'); + final bsAbnormal = _recordIsAbnormal( + bs, + 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 v = (bo['value'] as num).toDouble(); - if (v < 95) abnormals.add('血氧 ${v.toStringAsFixed(0)}% 偏低'); + final boAbnormal = _recordIsAbnormal( + bo, + 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; @@ -1545,8 +1580,8 @@ class ChatMessagesView extends ConsumerWidget { '健康指标', trailing: trailing, status: 'warning', - iconColor: healthIconColor, - iconBg: healthIconBg, + iconColor: AppColors.warning, + iconBg: AppColors.warningLight, onTap: () => pushRoute(ref, 'trend'), ), ); @@ -1749,7 +1784,7 @@ class ChatMessagesView extends ConsumerWidget { children: [ Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(16, 12, 16, 10), + padding: const EdgeInsets.fromLTRB(16, 13, 16, 11), decoration: const BoxDecoration( gradient: LinearGradient( begin: Alignment.centerLeft, @@ -1760,26 +1795,13 @@ class ChatMessagesView extends ConsumerWidget { ), child: Row( 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( '今日健康', style: TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, + fontSize: 16, + fontWeight: FontWeight.w800, color: AppColors.textPrimary, + height: 1.15, ), ), const Spacer(), @@ -1867,6 +1889,8 @@ class ChatMessagesView extends ConsumerWidget { Expanded( child: Text( trailing ?? label, + maxLines: 1, + overflow: TextOverflow.ellipsis, style: const TextStyle( fontSize: 16, 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 { @@ -1906,7 +1951,7 @@ class _AgentMark extends StatelessWidget { ? Alignment.bottomCenter : Alignment.bottomRight, ), - borderRadius: BorderRadius.circular(colors.verticalGradient ? 18 : 20), + borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( color: colors.accent.withValues(alpha: 0.18), diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index 6ef03ab..762c206 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; @@ -21,8 +22,7 @@ class MedicationCheckInPage extends ConsumerStatefulWidget { } class _MedicationCheckInPageState extends ConsumerState { - static const _medBlue = Color(0xFF60A5FA); - static const _medViolet = Color(0xFF8B5CF6); + static const _visual = AppModuleVisuals.medication; static const _softGreen = Color(0xFFEAF8EF); Future>>? _future; @@ -67,11 +67,7 @@ class _MedicationCheckInPageState extends ConsumerState { } catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); if (mounted) { - AppToast.show( - context, - '打卡失败,请稍后重试', - type: AppToastType.error, - ); + AppToast.show(context, '打卡失败,请稍后重试', type: AppToastType.error); } } finally { if (mounted) setState(() => _busyDoses.remove(doseKey)); @@ -212,14 +208,7 @@ class _CheckInSummary extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - _MedicationCheckInPageState._medBlue, - _MedicationCheckInPageState._medViolet, - ], - ), + gradient: _MedicationCheckInPageState._visual.gradient, borderRadius: BorderRadius.circular(14), ), child: const Icon( @@ -254,10 +243,10 @@ class _CheckInSummary extends StatelessWidget { ), Text( '${(progress * 100).round()}%', - style: const TextStyle( + style: TextStyle( fontSize: 22, fontWeight: FontWeight.w900, - color: _MedicationCheckInPageState._medViolet, + color: _MedicationCheckInPageState._visual.color, ), ), ], @@ -269,8 +258,8 @@ class _CheckInSummary extends StatelessWidget { minHeight: 9, value: progress, backgroundColor: AppColors.cardInner, - valueColor: const AlwaysStoppedAnimation( - _MedicationCheckInPageState._medBlue, + valueColor: AlwaysStoppedAnimation( + _MedicationCheckInPageState._visual.color, ), ), ), @@ -300,7 +289,7 @@ class _CheckInSummary extends StatelessWidget { label: '总次数', value: '$total', icon: Icons.format_list_numbered_outlined, - color: _MedicationCheckInPageState._medViolet, + color: _MedicationCheckInPageState._visual.color, ), ), ], @@ -415,7 +404,7 @@ class _MedicationDoseCard extends StatelessWidget { decoration: BoxDecoration( color: allTaken ? _MedicationCheckInPageState._softGreen - : AppColors.infoLight, + : _MedicationCheckInPageState._visual.lightColor, borderRadius: BorderRadius.circular(14), border: Border.all(color: AppColors.borderLight), ), @@ -423,7 +412,7 @@ class _MedicationDoseCard extends StatelessWidget { allTaken ? Icons.done_all_rounded : Icons.medication_liquid, color: allTaken ? AppTheme.success - : _MedicationCheckInPageState._medBlue, + : _MedicationCheckInPageState._visual.color, size: 24, ), ), @@ -613,7 +602,7 @@ class _DoseRow extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: isTaken ? Colors.white - : _MedicationCheckInPageState._medBlue, + : _MedicationCheckInPageState._visual.color, foregroundColor: isTaken ? AppTheme.success : Colors.white, diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 70213bd..2c3b774 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -3,7 +3,6 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; import '../../core/app_design_tokens.dart'; -import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; @@ -19,8 +18,6 @@ class MedicationEditPage extends ConsumerStatefulWidget { } class _MedicationEditPageState extends ConsumerState { - static const _visual = AppModuleVisuals.medication; - final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _notesCtrl = TextEditingController(); @@ -202,7 +199,7 @@ class _MedicationEditPageState extends ConsumerState { decoration: BoxDecoration( color: Colors.white, borderRadius: AppRadius.mdBorder, - border: Border.all(color: _visual.borderColor, width: 1), + border: Border.all(color: AppColors.border, width: 1), ), child: Text( '${_times[i].hour.toString().padLeft(2, '0')}:${_times[i].minute.toString().padLeft(2, '0')}', @@ -235,19 +232,9 @@ class _MedicationEditPageState extends ConsumerState { const SizedBox(height: 16), _field('备注', _notesCtrl, hint: '如:饭后服用、睡前'), const SizedBox(height: 32), - SizedBox( - width: double.infinity, - child: ElevatedButton( - 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), - ), + _GradientOutlineButton( + onPressed: _loading ? null : _save, + label: _loading ? '保存中...' : '保存', ), const SizedBox(height: 20), ], @@ -271,7 +258,7 @@ class _MedicationEditPageState extends ConsumerState { fillColor: AppTheme.surface, border: _inputBorder(AppColors.border), enabledBorder: _inputBorder(AppColors.border), - focusedBorder: _inputBorder(_visual.color), + focusedBorder: _inputBorder(AppColors.primary), contentPadding: const EdgeInsets.symmetric( horizontal: 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) { return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}'; } diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index cc65c64..c5e2319 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -50,11 +50,9 @@ class _MedicationListPageState extends ConsumerState { ), title: const Text('用药管理'), ), - floatingActionButton: FloatingActionButton( - onPressed: () => pushRoute(ref, 'medicationEdit'), - backgroundColor: _medVisual.color, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: const Icon(Icons.add, size: 28, color: Colors.white), + floatingActionButton: _GradientFab( + gradient: _medVisual.gradient, + onTap: () => pushRoute(ref, 'medicationEdit'), ), body: AppFutureView>>( future: _future, @@ -136,6 +134,41 @@ class _MedicationListPageState extends ConsumerState { } } +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 { final List children; diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index db8f100..4f4ae9d 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -342,20 +342,24 @@ class _UnreadHint extends StatelessWidget { margin: const EdgeInsets.fromLTRB(0, 2, 0, 14), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), + color: AppColors.notificationLight, borderRadius: AppRadius.mdBorder, - border: Border.all(color: const Color(0xFFFDE68A)), + border: Border.all(color: AppColors.notificationBorder), ), child: Row( children: [ - const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)), + const Icon( + LucideIcons.bellRing, + size: 18, + color: AppColors.notification, + ), const SizedBox(width: 9), Text( '你有 $unreadCount 条未读消息', style: const TextStyle( fontSize: 15, fontWeight: FontWeight.w800, - color: Color(0xFF92400E), + color: AppColors.notification, ), ), ], @@ -370,23 +374,14 @@ class _SectionTitle extends StatelessWidget { @override Widget build(BuildContext context) => _SectionShell( - child: Row( - children: [ - const Icon( - LucideIcons.calendarDays, - size: 18, - color: AppColors.primary, - ), - const SizedBox(width: 8), - Text( - text, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, - ), - ), - ], + child: Text( + text, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + letterSpacing: 0, + ), ), ); } @@ -421,34 +416,29 @@ class _CollapsibleSectionTitle extends StatelessWidget { child: _SectionShell( child: Row( children: [ - const Icon( - LucideIcons.history, - size: 18, - color: AppColors.textSecondary, - ), - const SizedBox(width: 8), Text( '$text · $count', style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w900, - color: AppColors.textPrimary, + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + letterSpacing: 0, ), ), const Spacer(), Text( expanded ? '收起' : '展开', style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w800, - color: AppColors.textSecondary, + fontSize: 12, + fontWeight: FontWeight.w700, + color: AppColors.textHint, ), ), - const SizedBox(width: 8), + const SizedBox(width: 4), Icon( expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down, - size: 22, - color: AppColors.textSecondary, + size: 18, + color: AppColors.textHint, ), ], ), @@ -483,50 +473,17 @@ class _NotificationRow extends StatelessWidget { padding: const EdgeInsets.fromLTRB(14, 13, 12, 12), child: Row( children: [ - Stack( - clipBehavior: Clip.none, - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: LinearGradient( - 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, - ), + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + gradient: visual.gradient, + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: visual.borderColor.withValues(alpha: 0.85), ), - if (!item.isRead) - Positioned( - 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, - ), - ), - ), - ), - ], + ), + child: Icon(visual.icon, size: 24, color: Colors.white), ), const SizedBox(width: 13), Expanded( @@ -594,14 +551,22 @@ class _NotificationRow extends StatelessWidget { ], ), ), - if (item.actionType != null) ...[ - const SizedBox(width: 6), - const Icon( - Icons.chevron_right_rounded, - size: 22, - color: AppColors.textSecondary, + const SizedBox(width: 10), + SizedBox( + width: 12, + child: Center( + child: item.isRead + ? 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 Color color; final Color lightColor; + final Color borderColor; + final LinearGradient gradient; 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) { return _NotificationVisual( visual.icon, visual.color, visual.lightColor, + visual.borderColor, + visual.gradient, visual.label, ); } factory _NotificationVisual.of(InAppNotification item) { - switch (item.actionType) { + final kind = item.actionType ?? item.type; + switch (kind) { case 'exercise': return _NotificationVisual.fromModule(AppModuleVisuals.exercise); case 'health': @@ -664,6 +641,12 @@ class _NotificationVisual { LucideIcons.triangleAlert, Color(0xFFEF4444), Color(0xFFFEE2E2), + Color(0xFFFECACA), + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)], + ), '紧急', ) : _NotificationVisual.fromModule(AppModuleVisuals.health); @@ -673,11 +656,21 @@ class _NotificationVisual { LucideIcons.fileWarning, Color(0xFFEF4444), Color(0xFFFEE2E2), + Color(0xFFFECACA), + LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFF7A7A), Color(0xFFEF4444)], + ), '报告', ) : _NotificationVisual.fromModule(AppModuleVisuals.report); - default: + case 'medication': return _NotificationVisual.fromModule(AppModuleVisuals.medication); + case 'diet': + return _NotificationVisual.fromModule(AppModuleVisuals.diet); + default: + return _NotificationVisual.fromModule(AppModuleVisuals.notification); } } } diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index c47de96..0171e42 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -12,6 +12,7 @@ import '../widgets/common_widgets.dart'; import '../widgets/app_error_state.dart'; import '../widgets/app_future_view.dart'; import '../widgets/app_toast.dart'; +import 'diet/diet_record_logic.dart'; /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) class DietRecordListPage extends ConsumerStatefulWidget { @@ -94,6 +95,7 @@ class _DietRecordListPageState extends ConsumerState { } final todayRecords = _dayRecords(_selectedDate); + final isSelectedToday = _dateKey(_selectedDate) == _dateKey(DateTime.now()); final totalCal = todayRecords.fold( 0, (s, r) => s + ((r['totalCalories'] as num?)?.toInt() ?? 0), @@ -114,274 +116,247 @@ class _DietRecordListPageState extends ConsumerState { ), ), body: ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 32), children: [ - // 热量趋势 - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Text( - '热量趋势', - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w600, - ), - ), - const Spacer(), - _Toggle( - '7天', - _trendDays == 7, - () => setState(() => _trendDays = 7), - ), - const SizedBox(width: 6), - _Toggle( - '30天', - _trendDays == 30, - () => setState(() => _trendDays = 30), - ), - ], - ), - const SizedBox(height: 10), - SizedBox(height: 120, child: _TrendChart(_trendData())), - ], - ), + _DietDaySummary( + date: _selectedDate, + totalCalories: totalCal, + count: todayRecords.length, ), const SizedBox(height: 12), - // 饮食日历 - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - '饮食日历', - style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600), - ), - const SizedBox(height: 8), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: List.generate(7, (i) { - final d = DateTime.now().subtract(Duration(days: 6 - i)); - final cal = _dayCalories(d); - final isToday = _dateKey(d) == _dateKey(DateTime.now()); - final isSel = _dateKey(d) == _dateKey(_selectedDate); - return GestureDetector( - onTap: () => setState(() => _selectedDate = d), - child: Container( - width: 42, - margin: const EdgeInsets.symmetric(horizontal: 3), - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: BoxDecoration( - color: isSel - ? AppColors.primary - : (isToday - ? AppColors.primarySoft - : Colors.white), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSel - ? AppColors.primary - : (isToday - ? AppColors.primary - : AppColors.border), - ), - ), - child: Column( - children: [ - Text( - ['一', '二', '三', '四', '五', '六', '日'][d.weekday - - 1], - style: TextStyle( - fontSize: 10, - color: isSel - ? Colors.white - : (isToday - ? AppColors.primary - : AppColors.textHint), - ), - ), - Text( - '${d.day}', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: isSel - ? Colors.white - : (isToday - ? AppColors.primary - : AppColors.textPrimary), - ), - ), - if (cal > 0) - Text( - '$cal', - style: TextStyle( - fontSize: 9, - color: isSel - ? Colors.white70 - : AppColors.textHint, - ), - ), - ], - ), - ), - ); - }), - ), - ), - ], - ), - ), - const SizedBox(height: 12), - // 当日详情 + _buildDateStrip(), + const SizedBox(height: 18), Row( children: [ Text( - '${_selectedDate.month}月${_selectedDate.day}日', + isSelectedToday + ? '今天的饮食' + : '${_selectedDate.month}月${_selectedDate.day}日饮食', style: const TextStyle( fontSize: 16, - fontWeight: FontWeight.w700, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, ), ), - const SizedBox(width: 8), - Text( - '共 $totalCal 千卡', - style: const TextStyle(fontSize: 14, color: AppColors.textHint), - ), const Spacer(), Text( - '${todayRecords.length}条', - style: const TextStyle(fontSize: 13, color: AppColors.textHint), + '${todayRecords.length} 条 · $totalCal 千卡', + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), ), ], ), const SizedBox(height: 8), if (todayRecords.isEmpty) - const Padding( - padding: EdgeInsets.all(20), - child: Center( - child: Text( - '当天暂无记录', - style: TextStyle(color: AppColors.textHint), + const _DietEmptyDay() + else + ClipRRect( + borderRadius: AppRadius.mdBorder, + child: ColoredBox( + color: Colors.white, + child: Column( + children: [ + for (var i = 0; i < todayRecords.length; i++) + _buildDietRecordRow( + todayRecords[i], + showDivider: i < todayRecords.length - 1, + ), + ], ), ), ), - ...todayRecords.map((d) { - final items = - (d['foodItems'] as List?)?.cast>() ?? []; - final mealNames = { - 'Breakfast': '早餐', - 'Lunch': '午餐', - 'Dinner': '晚餐', - 'Snack': '加餐', - }; - final mealIcons = { - 'Breakfast': Icons.wb_sunny, - 'Lunch': Icons.wb_sunny_outlined, - 'Dinner': Icons.nights_stay, - 'Snack': Icons.cookie, - }; - final mealLabel = mealNames[d['mealType']?.toString()] ?? ''; - final mealIcon = - mealIcons[d['mealType']?.toString()] ?? Icons.restaurant; - final cal = d['totalCalories'] ?? 0; - final id = d['id']?.toString() ?? ''; - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: ClipRRect( - borderRadius: BorderRadius.circular(14), - child: _SwipeAction( - onEdit: () => _showEditDialog(id, cal), - onDelete: () => _delete(id), - child: Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(12), - ), - child: Icon( - mealIcon, - size: 20, - color: AppColors.primary, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - mealLabel, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w600, - color: AppColors.textPrimary, - ), - ), - const SizedBox(width: 8), - Text( - '$cal 千卡', - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ], - ), - if (items.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 3), - child: Text( - items.map((f) => f['name']).join(' · '), - style: const TextStyle( - fontSize: 13, - color: AppColors.textHint, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ), - ], - ), - ), - ], - ), + const SizedBox(height: 18), + _DietTrendPanel( + trendDays: _trendDays, + trendData: _trendData(), + onSelectDays: (days) => setState(() => _trendDays = days), + ), + ], + ), + ); + } + + Widget _buildDateStrip() { + final dates = recentDietDates(DateTime.now()); + return Row( + children: List.generate(dates.length, (i) { + final d = dates[i]; + final cal = _dayCalories(d); + final isToday = _dateKey(d) == _dateKey(DateTime.now()); + final isSel = _dateKey(d) == _dateKey(_selectedDate); + return Expanded( + child: Padding( + padding: EdgeInsets.only(right: i == dates.length - 1 ? 0 : 5), + child: GestureDetector( + onTap: () => setState(() => _selectedDate = d), + child: Container( + padding: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + gradient: isSel ? AppColors.dietGradient : null, + color: isSel + ? null + : (isToday ? AppColors.dietLight : Colors.white), + borderRadius: AppRadius.mdBorder, + border: Border.all( + color: isSel + ? Colors.transparent + : (isToday + ? AppColors.dietBorder + : AppColors.borderLight), ), + boxShadow: isSel ? AppColors.cardShadowLight : null, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + FittedBox( + fit: BoxFit.scaleDown, + child: Text( + isToday + ? '今天' + : ['一', '二', '三', '四', '五', '六', '日'][d.weekday - + 1], + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.w700, + color: isSel ? Colors.white : AppColors.textHint, + ), + ), + ), + const SizedBox(height: 3), + Text( + '${d.day}', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w900, + color: isSel ? Colors.white : AppColors.textPrimary, + ), + ), + if (cal > 0) ...[ + const SizedBox(height: 2), + Text( + '$cal', + style: TextStyle( + fontSize: 10, + color: isSel ? Colors.white70 : AppColors.textHint, + ), + ), + ], + ], ), ), - ); - }), - ], + ), + ), + ); + }), + ); + } + + Widget _buildDietRecordRow( + Map record, { + required bool showDivider, + }) { + final items = + (record['foodItems'] as List?)?.cast>() ?? []; + final mealNames = { + 'Breakfast': '早餐', + 'Lunch': '午餐', + 'Dinner': '晚餐', + 'Snack': '加餐', + }; + final mealIcons = { + 'Breakfast': Icons.wb_sunny, + 'Lunch': Icons.wb_sunny_outlined, + 'Dinner': Icons.nights_stay, + 'Snack': Icons.cookie, + }; + final mealLabel = mealNames[record['mealType']?.toString()] ?? ''; + final mealIcon = + mealIcons[record['mealType']?.toString()] ?? Icons.restaurant; + final cal = record['totalCalories'] ?? 0; + final id = record['id']?.toString() ?? ''; + + return _SwipeAction( + onEdit: () => _showEditDialog(id, cal), + onDelete: () => _delete(id), + child: Material( + color: Colors.white, + child: InkWell( + onTap: () => pushRoute(ref, 'dietDetail', params: {'id': id}), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), + child: Column( + children: [ + Row( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: AppColors.dietLight, + borderRadius: AppRadius.smBorder, + ), + child: Icon(mealIcon, size: 19, color: AppColors.diet), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + mealLabel, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w700, + color: AppColors.textPrimary, + ), + ), + const SizedBox(width: 8), + Text( + '$cal 千卡', + style: const TextStyle( + fontSize: 13, + color: AppColors.textSecondary, + ), + ), + ], + ), + if (items.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + items.map((f) => f['name']).join(' · '), + style: const TextStyle( + fontSize: 13, + color: AppColors.textHint, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + if (showDivider) + const Padding( + padding: EdgeInsets.only(left: 48, top: 12), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], + ), + ), + ), ), ); } @@ -418,6 +393,145 @@ class _DietRecordListPageState extends ConsumerState { } } +class _DietDaySummary extends StatelessWidget { + final DateTime date; + final int totalCalories; + final int count; + + const _DietDaySummary({ + required this.date, + required this.totalCalories, + required this.count, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(16, 15, 16, 14), + decoration: BoxDecoration( + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFF8DB), Color(0xFFFFFFFF)], + ), + borderRadius: AppRadius.xlBorder, + boxShadow: AppColors.cardShadowLight, + ), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '${date.month}月${date.day}日', + style: const TextStyle( + fontSize: 20, + fontWeight: FontWeight.w900, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 5), + Text( + '共 $totalCalories 千卡 · $count 条记录', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w700, + color: AppColors.textSecondary, + ), + ), + ], + ), + ), + Container( + width: 42, + height: 42, + decoration: BoxDecoration( + gradient: AppColors.dietGradient, + borderRadius: AppRadius.mdBorder, + ), + child: const Icon( + Icons.restaurant_menu, + size: 22, + color: Colors.white, + ), + ), + ], + ), + ); + } +} + +class _DietEmptyDay extends StatelessWidget { + const _DietEmptyDay(); + + @override + Widget build(BuildContext context) => Container( + width: double.infinity, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 24), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.mdBorder, + ), + child: const Text( + '当天暂无记录', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textHint, + ), + ), + ); +} + +class _DietTrendPanel extends StatelessWidget { + final int trendDays; + final List trendData; + final ValueChanged onSelectDays; + + const _DietTrendPanel({ + required this.trendDays, + required this.trendData, + required this.onSelectDays, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.fromLTRB(14, 13, 14, 14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + boxShadow: AppColors.cardShadowLight, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + '热量趋势', + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const Spacer(), + _Toggle('7天', trendDays == 7, () => onSelectDays(7)), + const SizedBox(width: 6), + _Toggle('30天', trendDays == 30, () => onSelectDays(30)), + ], + ), + const SizedBox(height: 12), + SizedBox(height: 112, child: _TrendChart(trendData)), + ], + ), + ); + } +} + class _Toggle extends StatelessWidget { final String l; final bool a; @@ -429,9 +543,10 @@ class _Toggle extends StatelessWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: a ? AppColors.primary : Colors.white, + gradient: a ? AppColors.dietGradient : null, + color: a ? null : Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: a ? AppColors.primary : AppColors.border), + border: Border.all(color: a ? Colors.transparent : AppColors.border), ), child: Text( l, @@ -484,7 +599,8 @@ class _TrendChart extends StatelessWidget { Container( height: h, decoration: BoxDecoration( - color: data[i] > 0 ? AppColors.primary : AppColors.border, + gradient: data[i] > 0 ? AppColors.dietGradient : null, + color: data[i] > 0 ? null : AppColors.border, borderRadius: BorderRadius.circular(2), ), ), @@ -541,7 +657,7 @@ class _SwipeAction extends StatelessWidget { ); } -/// 饮食记录详情页(保留原功能)""" +/// 饮食记录详情页 class DietRecordDetailPage extends ConsumerWidget { final String id; const DietRecordDetailPage({super.key, required this.id}); @@ -571,7 +687,10 @@ class DietRecordDetailPage extends ConsumerWidget { if (d.isEmpty) return const Center(child: Text('记录不存在')); final items = (d['foodItems'] as List?)?.cast>() ?? []; - final totalCal = d['totalCalories'] ?? 0; + final totalCal = (d['totalCalories'] as num?)?.toInt() ?? 0; + final protein = (totalCal * 0.16 / 4).round(); + final carbs = (totalCal * 0.52 / 4).round(); + final fat = (totalCal * 0.28 / 9).round(); final mealNames = { 'Breakfast': '早餐', 'Lunch': '午餐', @@ -579,97 +698,161 @@ class DietRecordDetailPage extends ConsumerWidget { 'Snack': '加餐', }; return ListView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 32), children: [ Container( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: const Color(0xFFE8ECF0)), - boxShadow: [AppTheme.shadowLight], + gradient: const LinearGradient( + begin: Alignment.topLeft, + end: Alignment.bottomRight, + colors: [Color(0xFFFFF9E8), Colors.white], + ), + borderRadius: AppRadius.xlBorder, + boxShadow: AppColors.cardShadowLight, ), - child: Row( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppColors.iconBg, - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: const Icon( - Icons.restaurant, - size: 28, - color: AppTheme.primary, - ), - ), - const SizedBox(width: 14), - Column( - crossAxisAlignment: CrossAxisAlignment.start, + Row( children: [ - Text( - mealNames[d['mealType']?.toString()] ?? '', - style: const TextStyle( - fontSize: 21, - fontWeight: FontWeight.w700, + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: AppColors.dietGradient, + borderRadius: AppRadius.mdBorder, + ), + child: const Icon( + Icons.restaurant_menu, + size: 21, + color: Colors.white, + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + mealNames[d['mealType']?.toString()] ?? '饮食记录', + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + Text( + d['recordedAt']?.toString() ?? '', + style: const TextStyle( + fontSize: 13, + color: AppColors.textHint, + ), + ), + ], ), ), Text( - '$totalCal 千卡 · ${d['recordedAt'] ?? ''}', + '$totalCal 千卡', style: const TextStyle( fontSize: 16, - color: AppColors.textHint, + fontWeight: FontWeight.w800, + color: Color(0xFF9A3412), ), ), ], ), + const SizedBox(height: 18), + Row( + children: [ + _DietDetailMetric(label: '蛋白质', value: '$protein g'), + _DietDetailMetric(label: '碳水', value: '$carbs g'), + _DietDetailMetric(label: '脂肪', value: '$fat g'), + _DietDetailMetric( + label: '食物', + value: '${items.length} 种', + ), + ], + ), + const SizedBox(height: 8), + const Text( + '营养数据根据本餐总热量估算,仅供日常记录参考', + style: TextStyle(fontSize: 11, color: AppColors.textHint), + ), ], ), ), - const SizedBox(height: 16), - ...items.map( - (f) => Container( - margin: const EdgeInsets.only(bottom: 8), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: const Color(0xFFE8ECF0)), - boxShadow: [AppTheme.shadowLight], - ), - child: Row( + const SizedBox(height: 20), + const Text( + '识别结果', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 8), + ClipRRect( + borderRadius: AppRadius.xlBorder, + child: ColoredBox( + color: Colors.white, + child: Column( children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - f['name']?.toString() ?? '', - style: const TextStyle( - fontSize: 19, - fontWeight: FontWeight.w600, - ), - ), - if ((f['portion']?.toString() ?? '').isNotEmpty) - Text( - f['portion']!.toString(), - style: const TextStyle( - fontSize: 16, - color: AppColors.textSecondary, + for (var i = 0; i < items.length; i++) ...[ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 14, + ), + child: Row( + children: [ + Expanded( + child: Text( + items[i]['name']?.toString() ?? '', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textPrimary, + ), ), ), - ], + SizedBox( + width: 86, + child: Text( + items[i]['portion']?.toString() ?? '', + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: AppColors.textSecondary, + ), + ), + ), + SizedBox( + width: 78, + child: Text( + '${items[i]['calories'] ?? 0} 千卡', + textAlign: TextAlign.right, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Color(0xFF9A3412), + ), + ), + ), + ], + ), ), - ), - Text( - '${f['calories'] ?? 0} kcal', - style: const TextStyle( - fontSize: 18, - color: AppTheme.primary, - fontWeight: FontWeight.w600, - ), - ), + if (i < items.length - 1) + const Padding( + padding: EdgeInsets.only(left: 15), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], ], ), ), @@ -682,6 +865,35 @@ class DietRecordDetailPage extends ConsumerWidget { } } +class _DietDetailMetric extends StatelessWidget { + final String label; + final String value; + + const _DietDetailMetric({required this.label, required this.value}); + + @override + Widget build(BuildContext context) => Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + value, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + ), + ), + const SizedBox(height: 2), + Text( + label, + style: const TextStyle(fontSize: 12, color: AppColors.textHint), + ), + ], + ), + ); +} + /// 运动计划列表(含打卡) class ExercisePlanPage extends ConsumerStatefulWidget { const ExercisePlanPage({super.key}); @@ -691,8 +903,6 @@ class ExercisePlanPage extends ConsumerStatefulWidget { class _ExercisePlanPageState extends ConsumerState { static const _exerciseVisual = AppModuleVisuals.exercise; - static const _exerciseBlue = AppColors.exercise; - static const _exerciseViolet = Color(0xFF059669); Future>>? _future; final Set _busyItems = {}; @@ -750,14 +960,10 @@ class _ExercisePlanPageState extends ConsumerState { ), title: const Text('运动计划'), ), - floatingActionButton: FloatingActionButton( - onPressed: () { - pushRoute(ref, 'exerciseCreate'); - }, - backgroundColor: AppColors.exercise, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: const Icon(Icons.add, color: Colors.white), + floatingActionButton: _ModuleGradientFab( + gradient: _exerciseVisual.gradient, + shadowColor: _exerciseVisual.color, + onTap: () => pushRoute(ref, 'exerciseCreate'), ), body: AppFutureView>>( future: _future, @@ -818,6 +1024,46 @@ class _ExercisePlanPageState extends ConsumerState { } } +class _ModuleGradientFab extends StatelessWidget { + final Gradient gradient; + final Color shadowColor; + final VoidCallback onTap; + + const _ModuleGradientFab({ + required this.gradient, + required this.shadowColor, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return Container( + width: 56, + height: 56, + decoration: BoxDecoration( + gradient: gradient, + borderRadius: AppRadius.lgBorder, + boxShadow: [ + BoxShadow( + color: shadowColor.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 _ExercisePlanOverviewCard extends StatelessWidget { final Map plan; final List> items; @@ -870,14 +1116,7 @@ class _ExercisePlanOverviewCard extends StatelessWidget { width: 44, height: 44, decoration: BoxDecoration( - gradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [ - _ExercisePlanPageState._exerciseBlue, - _ExercisePlanPageState._exerciseViolet, - ], - ), + gradient: _ExercisePlanPageState._exerciseVisual.gradient, borderRadius: BorderRadius.circular(14), ), child: Icon( @@ -920,16 +1159,18 @@ class _ExercisePlanOverviewCard extends StatelessWidget { vertical: 5, ), decoration: BoxDecoration( - color: AppColors.infoLight, + color: _ExercisePlanPageState._exerciseVisual.lightColor, borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFBFDBFE)), + border: Border.all( + color: _ExercisePlanPageState._exerciseVisual.borderColor, + ), ), child: Text( '$done/$total 天', - style: const TextStyle( + style: TextStyle( fontSize: 13, fontWeight: FontWeight.w800, - color: _ExercisePlanPageState._exerciseBlue, + color: _ExercisePlanPageState._exerciseVisual.color, ), ), ), @@ -942,8 +1183,8 @@ class _ExercisePlanOverviewCard extends StatelessWidget { minHeight: 8, value: progress.clamp(0.0, 1.0), backgroundColor: AppColors.cardInner, - valueColor: const AlwaysStoppedAnimation( - _ExercisePlanPageState._exerciseBlue, + valueColor: AlwaysStoppedAnimation( + _ExercisePlanPageState._exerciseVisual.color, ), ), ), @@ -1010,7 +1251,7 @@ class _ExerciseTodayInlineTask extends StatelessWidget { size: 22, color: isCompleted ? AppTheme.success - : _ExercisePlanPageState._exerciseBlue, + : _ExercisePlanPageState._exerciseVisual.color, ), const SizedBox(width: 10), Expanded( @@ -1049,7 +1290,7 @@ class _ExerciseTodayInlineTask extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: isCompleted ? Colors.white - : _ExercisePlanPageState._exerciseBlue, + : _ExercisePlanPageState._exerciseVisual.color, foregroundColor: isCompleted ? AppTheme.success : Colors.white, disabledBackgroundColor: AppColors.cardInner, disabledForegroundColor: AppColors.textHint, @@ -1143,10 +1384,10 @@ class _ExerciseCheckInSummary extends StatelessWidget { ), Text( '${(progress * 100).round()}%', - style: const TextStyle( + style: TextStyle( fontSize: 22, fontWeight: FontWeight.w900, - color: _ExercisePlanPageState._exerciseViolet, + color: _ExercisePlanPageState._exerciseVisual.color, ), ), ], @@ -1158,8 +1399,8 @@ class _ExerciseCheckInSummary extends StatelessWidget { minHeight: 9, value: progress, backgroundColor: AppColors.cardInner, - valueColor: const AlwaysStoppedAnimation( - _ExercisePlanPageState._exerciseBlue, + valueColor: AlwaysStoppedAnimation( + _ExercisePlanPageState._exerciseVisual.color, ), ), ), @@ -1189,7 +1430,7 @@ class _ExerciseCheckInSummary extends StatelessWidget { label: '计划数', value: '$planCount', icon: Icons.view_week_outlined, - color: _ExercisePlanPageState._exerciseViolet, + color: _ExercisePlanPageState._exerciseVisual.color, ), ), ], @@ -1274,9 +1515,7 @@ class _ExercisePlanDetailPageState Future?>? _future; final Set _busyItems = {}; - static const _exerciseColor = Color(0xFF60A5FA); - static const _exerciseDark = Color(0xFF7C5CFF); - static const _exerciseSoft = Color(0xFFEFF6FF); + static const _exerciseVisual = AppModuleVisuals.exercise; @override void initState() { super.initState(); @@ -1453,12 +1692,13 @@ class _ExerciseHeroCard extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + gradient: + _ExercisePlanDetailPageState._exerciseVisual.gradient, borderRadius: BorderRadius.circular(12), ), child: const Icon( Icons.directions_run, - color: Color(0xFF60A5FA), + color: Colors.white, size: 24, ), ), @@ -1497,14 +1737,19 @@ class _ExerciseHeroCard extends StatelessWidget { vertical: 5, ), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: + _ExercisePlanDetailPageState._exerciseVisual.lightColor, borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFBFDBFE)), + border: Border.all( + color: _ExercisePlanDetailPageState + ._exerciseVisual + .borderColor, + ), ), child: Text( '$done/$total 天', - style: const TextStyle( - color: Color(0xFF60A5FA), + style: TextStyle( + color: _ExercisePlanDetailPageState._exerciseVisual.color, fontSize: 13, fontWeight: FontWeight.w700, ), @@ -1518,9 +1763,10 @@ class _ExerciseHeroCard extends StatelessWidget { child: LinearProgressIndicator( minHeight: 8, value: progress.clamp(0.0, 1.0), - backgroundColor: const Color(0xFFEFF6FF), - valueColor: const AlwaysStoppedAnimation( - Color(0xFF60A5FA), + backgroundColor: + _ExercisePlanDetailPageState._exerciseVisual.lightColor, + valueColor: AlwaysStoppedAnimation( + _ExercisePlanDetailPageState._exerciseVisual.color, ), ), ), @@ -1566,14 +1812,14 @@ class _TodayExerciseCard extends StatelessWidget { decoration: BoxDecoration( color: isDone ? AppColors.successLight - : _ExercisePlanDetailPageState._exerciseSoft, + : _ExercisePlanDetailPageState._exerciseVisual.lightColor, borderRadius: BorderRadius.circular(11), ), child: Icon( isDone ? Icons.check_circle : Icons.today_outlined, color: isDone ? AppTheme.success - : _ExercisePlanDetailPageState._exerciseColor, + : _ExercisePlanDetailPageState._exerciseVisual.color, size: 23, ), ), @@ -1612,7 +1858,7 @@ class _TodayExerciseCard extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: isDone ? Colors.white - : _ExercisePlanDetailPageState._exerciseColor, + : _ExercisePlanDetailPageState._exerciseVisual.color, foregroundColor: isDone ? AppTheme.success : Colors.white, disabledBackgroundColor: AppColors.cardInner, disabledForegroundColor: AppColors.textHint, @@ -1688,7 +1934,7 @@ class _ExerciseDayTile extends StatelessWidget { borderRadius: BorderRadius.circular(14), border: Border.all( color: isToday - ? _ExercisePlanDetailPageState._exerciseColor + ? _ExercisePlanDetailPageState._exerciseVisual.color : AppColors.borderLight, width: isToday ? 1.3 : 1, ), @@ -1701,14 +1947,14 @@ class _ExerciseDayTile extends StatelessWidget { decoration: BoxDecoration( color: isCompleted ? AppColors.successLight - : _ExercisePlanDetailPageState._exerciseSoft, + : _ExercisePlanDetailPageState._exerciseVisual.lightColor, borderRadius: BorderRadius.circular(12), ), child: Icon( isCompleted ? Icons.check : Icons.fitness_center, color: isCompleted ? AppTheme.success - : _ExercisePlanDetailPageState._exerciseDark, + : _ExercisePlanDetailPageState._exerciseVisual.color, size: 21, ), ), @@ -1729,7 +1975,7 @@ class _ExerciseDayTile extends StatelessWidget { style: FilledButton.styleFrom( backgroundColor: isCompleted ? Colors.white - : _ExercisePlanDetailPageState._exerciseColor, + : _ExercisePlanDetailPageState._exerciseVisual.color, foregroundColor: isCompleted ? AppTheme.success : Colors.white, disabledBackgroundColor: AppColors.cardInner, disabledForegroundColor: AppColors.textHint, @@ -2455,7 +2701,7 @@ class _HealthArchivePageState extends ConsumerState { height: 32, decoration: BoxDecoration( color: AppColors.errorLight, - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, ), child: const Icon( Icons.close, @@ -2502,7 +2748,7 @@ class _Section extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration( color: Colors.white, - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2514,7 +2760,7 @@ class _Section extends StatelessWidget { height: 30, decoration: BoxDecoration( color: _color.withValues(alpha: 0.10), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, ), child: Icon(icon, size: 18, color: _color), ), @@ -2563,15 +2809,15 @@ class _F extends StatelessWidget { vertical: 12, ), border: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: Color(0xFF60A5FA)), ), ), @@ -2601,7 +2847,7 @@ class _GenderF extends StatelessWidget { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, border: Border.all(color: AppColors.borderLight), ), child: Row( @@ -2632,7 +2878,7 @@ class _GenderChip extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: selected ? const Color(0xFF2563EB) : Colors.transparent, - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, ), child: Text( label, @@ -2676,15 +2922,15 @@ class _F2 extends StatelessWidget { vertical: 8, ), border: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, borderSide: const BorderSide(color: Color(0xFF60A5FA)), ), ), @@ -2714,7 +2960,7 @@ class _DateF extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, @@ -2765,7 +3011,7 @@ class _DateF2 extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, @@ -2815,7 +3061,7 @@ class _AddBtn extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: const Color(0xFFEFF6FF), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, border: Border.all(color: const Color(0xFFBFDBFE)), ), child: const Row( @@ -2847,10 +3093,10 @@ class _HealthCalendarPageState extends ConsumerState { Map>> _events = {}; Map? _selectedDayData; - static const _medBlue = Color(0xFF3B82F6); - static const _exGreen = Color(0xFF60A5FA); - static const _fupOrange = Color(0xFFF59E0B); - static const _healthBlue = Color(0xFF2563EB); + static const _medColor = AppColors.medication; + static const _exerciseColor = AppColors.exercise; + static const _followupColor = AppColors.followup; + static const _calendarColor = AppColors.calendar; @override void initState() { @@ -2960,11 +3206,11 @@ class _HealthCalendarPageState extends ConsumerState { child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ - _Legend('用药提醒', _medBlue), + _Legend('用药提醒', _medColor), const SizedBox(width: 10), - _Legend('运动计划', _exGreen), + _Legend('运动计划', _exerciseColor), const SizedBox(width: 10), - _Legend('复查随访', _fupOrange), + _Legend('复查随访', _followupColor), ], ), ), @@ -2985,7 +3231,7 @@ class _HealthCalendarPageState extends ConsumerState { icon: const Icon(Icons.chevron_left), style: IconButton.styleFrom( backgroundColor: const Color(0xFFF8FAFC), - foregroundColor: _healthBlue, + foregroundColor: _calendarColor, ), onPressed: () { setState( @@ -3005,7 +3251,7 @@ class _HealthCalendarPageState extends ConsumerState { icon: const Icon(Icons.chevron_right), style: IconButton.styleFrom( backgroundColor: const Color(0xFFF8FAFC), - foregroundColor: _healthBlue, + foregroundColor: _calendarColor, ), onPressed: () { setState( @@ -3075,16 +3321,16 @@ class _HealthCalendarPageState extends ConsumerState { margin: const EdgeInsets.all(2), decoration: BoxDecoration( color: isSel - ? _healthBlue + ? _calendarColor : (isToday - ? _healthBlue.withValues(alpha: 0.08) + ? _calendarColor.withValues(alpha: 0.08) : Colors.white), borderRadius: BorderRadius.circular(12), border: isSel - ? Border.all(color: _healthBlue, width: 1.5) + ? Border.all(color: _calendarColor, width: 1.5) : (isToday ? Border.all( - color: _healthBlue.withValues(alpha: 0.24), + color: _calendarColor.withValues(alpha: 0.24), width: 1, ) : null), @@ -3131,9 +3377,13 @@ class _HealthCalendarPageState extends ConsumerState { } Color _dotColor(String t) => switch (t) { - 'medication' => _medBlue, - 'exercise' => _exGreen, - 'followup' => _fupOrange, + 'medication' => _medColor, + 'exercise' => _exerciseColor, + 'followup' => _followupColor, + 'health' => AppColors.health, + 'diet' => AppColors.diet, + 'report' => AppColors.report, + 'device' => AppColors.device, _ => AppColors.textHint, }; @@ -3211,32 +3461,35 @@ class _HealthCalendarPageState extends ConsumerState { children: [ // 用药 if (meds.isNotEmpty) ...[ - _SectionTitle('用药提醒', _medBlue, Icons.medication), + _SectionTitle('用药提醒', _medColor, Icons.medication), ...meds.map( (m) => _EventCard( '${m['name'] ?? ''} ${m['dosage'] ?? ''}', '${m['timeOfDay'] ?? ''}', - _medBlue, + _medColor, ), ), ], // 运动 if (exs.isNotEmpty) ...[ - _SectionTitle('运动计划', _exGreen, Icons.fitness_center), + _SectionTitle('运动计划', _exerciseColor, Icons.fitness_center), ...exs.map( (e) => _EventCard( '${e['type'] ?? ''}', '${e['duration'] ?? 0}分钟', - _exGreen, + _exerciseColor, ), ), ], // 随访 if (fups.isNotEmpty) ...[ - _SectionTitle('复查随访', _fupOrange, Icons.event_note), + _SectionTitle('复查随访', _followupColor, Icons.event_note), ...fups.map( - (f) => - _EventCard(f['title'] ?? '', f['doctorName'] ?? '', _fupOrange), + (f) => _EventCard( + f['title'] ?? '', + f['doctorName'] ?? '', + _followupColor, + ), ), ], ], @@ -3290,7 +3543,7 @@ class _EventCard extends StatelessWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.border, width: 1.1), + border: Border.all(color: color.withValues(alpha: 0.18), width: 1.1), boxShadow: [AppTheme.shadowLight], ), child: Row( @@ -3299,7 +3552,7 @@ class _EventCard extends StatelessWidget { width: 34, height: 34, decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), + color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon(Icons.event_available, size: 18, color: color), diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 57f76d6..c9720fe 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; @@ -231,35 +232,35 @@ class NotificationPrefsPage extends ConsumerWidget { ), if (prefs['healthRecord'] ?? true) ...[ _SwitchTile( - title: ' · 血压', + title: '血压', value: prefs['healthRecord.bp'] ?? true, onChanged: (v) => ref .read(notificationPrefsProvider.notifier) .toggle('healthRecord.bp'), ), _SwitchTile( - title: ' · 心率', + title: '心率', value: prefs['healthRecord.hr'] ?? true, onChanged: (v) => ref .read(notificationPrefsProvider.notifier) .toggle('healthRecord.hr'), ), _SwitchTile( - title: ' · 血糖', + title: '血糖', value: prefs['healthRecord.glucose'] ?? true, onChanged: (v) => ref .read(notificationPrefsProvider.notifier) .toggle('healthRecord.glucose'), ), _SwitchTile( - title: ' · 血氧', + title: '血氧', value: prefs['healthRecord.spo2'] ?? true, onChanged: (v) => ref .read(notificationPrefsProvider.notifier) .toggle('healthRecord.spo2'), ), _SwitchTile( - title: ' · 体重', + title: '体重', value: prefs['healthRecord.weight'] ?? true, showDivider: false, onChanged: (v) => ref @@ -391,7 +392,7 @@ class _SettingsListSection extends StatelessWidget { children: [ _SectionTitle(title: title), ClipRRect( - borderRadius: BorderRadius.circular(AppTheme.rSm), + borderRadius: AppRadius.mdBorder, child: ColoredBox( color: AppTheme.surface, child: Column(children: children), @@ -425,71 +426,127 @@ class _SwitchTile extends StatelessWidget { @override Widget build(BuildContext context) { + final dense = icon == null && (subtitle == null || subtitle!.isEmpty); return Container( margin: EdgeInsets.zero, - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), - decoration: BoxDecoration( - color: AppTheme.surface, - border: showDivider - ? Border( - bottom: BorderSide( - color: AppColors.borderLight.withValues(alpha: 0.75), - ), - ) - : null, - ), - child: Row( + decoration: BoxDecoration(color: AppTheme.surface), + child: Column( children: [ - if (icon != null) ...[ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: iconBg ?? AppColors.iconBg, - borderRadius: BorderRadius.circular(AppTheme.rSm), - ), - child: Icon( - icon, - size: 20, - color: iconColor ?? AppColors.primary, - ), + Padding( + padding: EdgeInsets.symmetric( + horizontal: 14, + vertical: dense ? 8 : 14, ), - const SizedBox(width: 12), - ], - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: Row( children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - color: AppColors.textPrimary, - fontWeight: FontWeight.w700, + if (icon != null) ...[ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + 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) - const SizedBox(height: 2), - if (subtitle != null && subtitle!.isNotEmpty) - Text( - subtitle!, - style: TextStyle(fontSize: 13, color: AppTheme.textSub), - ), + _AppSwitch(value: value, onChanged: onChanged), ], ), ), - Switch( - value: value, - onChanged: onChanged, - activeThumbColor: AppTheme.primary, - activeTrackColor: AppColors.primaryLight, - ), + if (showDivider) + Padding( + padding: EdgeInsets.only(left: icon != null ? 60 : 14), + child: const Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), ], ), ); } } +class _AppSwitch extends StatelessWidget { + final bool value; + final ValueChanged 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 { final String label; final String time; @@ -508,7 +565,7 @@ class _TimeButton extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( border: Border.all(color: AppColors.border), - borderRadius: BorderRadius.circular(AppTheme.rSm), + borderRadius: AppRadius.mdBorder, ), child: Column( children: [ diff --git a/health_app/lib/widgets/app_empty_state.dart b/health_app/lib/widgets/app_empty_state.dart index a0b8322..a0aad20 100644 --- a/health_app/lib/widgets/app_empty_state.dart +++ b/health_app/lib/widgets/app_empty_state.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../core/app_colors.dart'; +import 'app_gradient_widgets.dart'; class AppEmptyState extends StatelessWidget { final IconData icon; @@ -32,7 +33,7 @@ class AppEmptyState extends StatelessWidget { border: Border.all(color: AppColors.borderLight), boxShadow: AppColors.cardShadowLight, ), - child: Icon(icon, size: 34, color: AppColors.primaryDark), + child: AppGradientIcon(icon: icon, size: 34), ), const SizedBox(height: 18), Text( diff --git a/health_app/lib/widgets/app_error_state.dart b/health_app/lib/widgets/app_error_state.dart index 34af0f9..5ce0103 100644 --- a/health_app/lib/widgets/app_error_state.dart +++ b/health_app/lib/widgets/app_error_state.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../core/app_colors.dart'; +import 'app_gradient_widgets.dart'; /// 统一的"加载失败"状态——与 AppEmptyState 风格一致,区别在于明确表达"出错了,可重试", /// 避免把网络/服务失败误显示成"暂无数据"。 @@ -32,10 +33,9 @@ class AppErrorState extends StatelessWidget { border: Border.all(color: AppColors.borderLight), boxShadow: AppColors.cardShadowLight, ), - child: const Icon( - Icons.cloud_off_outlined, + child: const AppGradientIcon( + icon: Icons.cloud_off_outlined, size: 34, - color: AppColors.primaryDark, ), ), const SizedBox(height: 18), @@ -61,17 +61,9 @@ class AppErrorState extends StatelessWidget { ], if (onRetry != null) ...[ const SizedBox(height: 20), - OutlinedButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh, size: 18), - label: const Text('重试'), - style: OutlinedButton.styleFrom( - foregroundColor: AppColors.primaryDark, - side: const BorderSide(color: AppColors.borderLight), - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(12), - ), - ), + SizedBox( + width: 116, + child: AppGradientOutlineButton(label: '重试', onTap: onRetry!), ), ], ], diff --git a/health_app/lib/widgets/app_gradient_widgets.dart b/health_app/lib/widgets/app_gradient_widgets.dart new file mode 100644 index 0000000..d68ff84 --- /dev/null +++ b/health_app/lib/widgets/app_gradient_widgets.dart @@ -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), + ), + ), + ), + ); + } +} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 4f0c3ab..47de318 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -380,25 +380,25 @@ class _NavigationSection extends StatelessWidget { icon: LucideIcons.folderHeart, title: '档案', route: 'healthArchive', - colors: const [Color(0xFF93C5FD), Color(0xFF60A5FA)], + colors: AppColors.healthGradient.colors, ), _NavItem( icon: LucideIcons.fileText, title: '报告', route: 'reports', - colors: const [Color(0xFFBFDBFE), Color(0xFF93C5FD)], + colors: AppColors.reportGradient.colors, ), _NavItem( icon: LucideIcons.pill, title: '用药', route: 'medications', - colors: const [Color(0xFFDDD6FE), Color(0xFFC4B5FD)], + colors: AppColors.medicationGradient.colors, ), _NavItem( icon: LucideIcons.utensils, title: '饮食', route: 'dietRecords', - colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)], + colors: AppColors.dietGradient.colors, ), _NavItem( icon: LucideIcons.calendarDays, @@ -416,13 +416,13 @@ class _NavigationSection extends StatelessWidget { icon: LucideIcons.footprints, title: '运动', route: 'exercisePlan', - colors: const [Color(0xFFA5F3FC), Color(0xFF67E8F9)], + colors: AppColors.exerciseGradient.colors, ), _NavItem( icon: LucideIcons.bluetooth, title: '设备', route: 'devices', - colors: const [Color(0xFF60A5FA), Color(0xFFC4B5FD)], + colors: AppColors.deviceGradient.colors, ), ]; @@ -466,14 +466,14 @@ class _NavTile extends StatelessWidget { }; List get _colors => switch (item.route) { - 'healthArchive' => const [AppColors.healthLight, AppColors.health], - 'reports' => const [AppColors.reportLight, AppColors.report], - 'medications' => const [AppColors.medicationLight, AppColors.medication], - 'dietRecords' => const [AppColors.dietLight, AppColors.diet], + 'healthArchive' => AppColors.healthGradient.colors, + 'reports' => AppColors.reportGradient.colors, + 'medications' => AppColors.medicationGradient.colors, + 'dietRecords' => AppColors.dietGradient.colors, 'calendar' => const [AppColors.calendarLight, AppColors.calendar], 'followups' => const [AppColors.followupLight, AppColors.followup], - 'exercisePlan' => const [AppColors.exerciseLight, AppColors.exercise], - 'devices' => const [AppColors.deviceLight, AppColors.device], + 'exercisePlan' => AppColors.exerciseGradient.colors, + 'devices' => AppColors.deviceGradient.colors, _ => item.colors, }; diff --git a/health_app/test/diet_meal_selection_test.dart b/health_app/test/diet_meal_selection_test.dart new file mode 100644 index 0000000..1edc8bb --- /dev/null +++ b/health_app/test/diet_meal_selection_test.dart @@ -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); + }); +} diff --git a/health_app/test/diet_record_logic_test.dart b/health_app/test/diet_record_logic_test.dart new file mode 100644 index 0000000..c4baae6 --- /dev/null +++ b/health_app/test/diet_record_logic_test.dart @@ -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); + }); +}