feat: 健康录入提醒 + 多指标异常 + 用药漏服 + UI 优化
后端 - NotificationPreference 增加健康录入提醒总开关和 5 子项(血压/心率/血糖/血氧/体重) - EF 迁移 AddHealthRecordReminder - 新增 GET/PUT /api/notification-prefs - 新增 HealthRecordReminderService 每天 9 点/12 点扫描未录入用户推送提醒 前端 - 通知偏好对接真实后端 API - 设置页新增「健康录入提醒」总开关+5 子项 - 通知中心右上角加齿轮跳设置页 - 今日健康卡片:4 项异常检测+多项合并;用药只显漏服并按时间窗口区分文案 - 侧边栏头像改圆形白底灰图标,去掉紫色品牌渐变 - 整体背景统一浅灰白 #F6F8FC,去掉浅紫渐变 - 设置页简化(去掉分组/彩色图标方块) - 健康档案保存按钮移到右上角,TextButton 全局默认黑色 - API baseUrl 跟随网络环境
This commit is contained in:
@@ -89,6 +89,13 @@ public sealed class NotificationPreference
|
|||||||
public bool FollowUpReminder { get; set; } = true;
|
public bool FollowUpReminder { get; set; } = true;
|
||||||
public bool DoctorReply { get; set; } = true;
|
public bool DoctorReply { get; set; } = true;
|
||||||
public bool AbnormalAlert { get; set; } = true;
|
public bool AbnormalAlert { get; set; } = true;
|
||||||
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
|
public bool HealthRecordReminder { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderBloodPressure { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderHeartRate { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderGlucose { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderSpO2 { get; set; } = true;
|
||||||
|
public bool HealthRecordReminderWeight { get; set; } = true;
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public User User { get; set; } = null!;
|
public User User { get; set; } = null!;
|
||||||
|
|||||||
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
1461
backend/src/Health.Infrastructure/Data/Migrations/20260624063234_AddHealthRecordReminder.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,84 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Health.Infrastructure.Data.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddHealthRecordReminder : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminder",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderBloodPressure",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderGlucose",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderHeartRate",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderSpO2",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "HealthRecordReminderWeight",
|
||||||
|
table: "NotificationPreferences",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminder",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderBloodPressure",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderGlucose",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderHeartRate",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderSpO2",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "HealthRecordReminderWeight",
|
||||||
|
table: "NotificationPreferences");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -679,6 +679,24 @@ namespace Health.Infrastructure.Data.Migrations
|
|||||||
b.Property<bool>("FollowUpReminder")
|
b.Property<bool>("FollowUpReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminder")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderBloodPressure")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderGlucose")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderHeartRate")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderSpO2")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("HealthRecordReminderWeight")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("MedicationReminder")
|
b.Property<bool>("MedicationReminder")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
using Health.Application.Notifications;
|
||||||
|
using Health.Domain.Enums;
|
||||||
|
|
||||||
|
namespace Health.WebApi.BackgroundServices;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 健康录入提醒服务——每天 9:00 / 12:00 扫描所有用户,
|
||||||
|
/// 对未录入指标且开关开启的用户推送提醒。
|
||||||
|
/// 中国时区 UTC+8。
|
||||||
|
/// </summary>
|
||||||
|
public sealed class HealthRecordReminderService(
|
||||||
|
IServiceScopeFactory scopeFactory,
|
||||||
|
ILogger<HealthRecordReminderService> logger) : BackgroundService
|
||||||
|
{
|
||||||
|
private readonly IServiceScopeFactory _scopeFactory = scopeFactory;
|
||||||
|
private readonly ILogger<HealthRecordReminderService> _logger = logger;
|
||||||
|
|
||||||
|
// 北京时间 9:00 和 12:00 触发
|
||||||
|
private static readonly int[] ReminderHoursCst = [9, 12];
|
||||||
|
|
||||||
|
private DateTime _lastFired = DateTime.MinValue;
|
||||||
|
|
||||||
|
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||||
|
{
|
||||||
|
_logger.LogInformation("健康录入提醒服务已启动");
|
||||||
|
while (!stoppingToken.IsCancellationRequested)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var nowCst = DateTime.UtcNow.AddHours(8);
|
||||||
|
// 当前小时是否在触发列表,且今天该小时还没触发过
|
||||||
|
if (ReminderHoursCst.Contains(nowCst.Hour)
|
||||||
|
&& (_lastFired.Date != nowCst.Date || _lastFired.Hour != nowCst.Hour))
|
||||||
|
{
|
||||||
|
await ScanAndPushAsync(nowCst, stoppingToken);
|
||||||
|
_lastFired = nowCst;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; }
|
||||||
|
catch (Exception ex) { _logger.LogError(ex, "健康录入提醒扫描异常"); }
|
||||||
|
|
||||||
|
await Task.Delay(TimeSpan.FromMinutes(5), stoppingToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ScanAndPushAsync(DateTime nowCst, CancellationToken ct)
|
||||||
|
{
|
||||||
|
using var scope = _scopeFactory.CreateScope();
|
||||||
|
var db = scope.ServiceProvider.GetRequiredService<AppDbContext>();
|
||||||
|
var producer = scope.ServiceProvider.GetRequiredService<IUserNotificationProducer>();
|
||||||
|
|
||||||
|
var todayStart = nowCst.Date.AddHours(-8); // 北京 0:00 转 UTC
|
||||||
|
var todayEnd = todayStart.AddDays(1);
|
||||||
|
|
||||||
|
// 取所有启用了健康录入提醒的用户偏好
|
||||||
|
var prefs = await db.NotificationPreferences
|
||||||
|
.Where(p => p.HealthRecordReminder)
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
foreach (var pref in prefs)
|
||||||
|
{
|
||||||
|
// 该用户当天已录入的指标
|
||||||
|
var recordedTypes = await db.HealthRecords
|
||||||
|
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)
|
||||||
|
.Select(r => r.MetricType)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(ct);
|
||||||
|
|
||||||
|
// 哪些指标启用开关但当天没录入
|
||||||
|
var missing = new List<(HealthMetricType type, bool enabled, string label)>
|
||||||
|
{
|
||||||
|
(HealthMetricType.BloodPressure, pref.HealthRecordReminderBloodPressure, "血压"),
|
||||||
|
(HealthMetricType.HeartRate, pref.HealthRecordReminderHeartRate, "心率"),
|
||||||
|
(HealthMetricType.Glucose, pref.HealthRecordReminderGlucose, "血糖"),
|
||||||
|
(HealthMetricType.SpO2, pref.HealthRecordReminderSpO2, "血氧"),
|
||||||
|
(HealthMetricType.Weight, pref.HealthRecordReminderWeight, "体重"),
|
||||||
|
};
|
||||||
|
|
||||||
|
var missingLabels = missing
|
||||||
|
.Where(m => m.enabled && !recordedTypes.Contains(m.type))
|
||||||
|
.Select(m => m.label)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (missingLabels.Count == 0) continue;
|
||||||
|
|
||||||
|
// 每天每个时段每个用户只推一次——SourceTaskId 用 userId + 日期 + 小时
|
||||||
|
var sourceId = MakeSourceId(pref.UserId, nowCst);
|
||||||
|
var summary = string.Join("、", missingLabels);
|
||||||
|
|
||||||
|
await producer.EnqueueAsync(pref.UserId, sourceId, new NotificationMessage(
|
||||||
|
Type: "health_record_reminder",
|
||||||
|
Title: nowCst.Hour < 12 ? "早安,记得录入今日健康数据" : "下午好,今日健康数据还未录入",
|
||||||
|
Message: $"建议录入:{summary}",
|
||||||
|
Severity: "info",
|
||||||
|
ActionType: "health",
|
||||||
|
ActionTargetId: null), ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Guid MakeSourceId(Guid userId, DateTime nowCst)
|
||||||
|
{
|
||||||
|
// 通过哈希构造稳定的 Guid,保证同一用户同一时段不重复
|
||||||
|
var key = $"hrr|{userId:N}|{nowCst:yyyyMMddHH}";
|
||||||
|
var bytes = System.Security.Cryptography.MD5.HashData(System.Text.Encoding.UTF8.GetBytes(key));
|
||||||
|
return new Guid(bytes);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,8 +56,77 @@ public static class NotificationEndpoints
|
|||||||
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
? Results.Ok(new { code = 0, data = new { deleted = true }, message = (string?)null })
|
||||||
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
: Results.NotFound(new { code = 404, data = (object?)null, message = "通知不存在" });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── 通知偏好设置 ──
|
||||||
|
var prefsGroup = app.MapGroup("/api/notification-prefs").RequireAuthorization();
|
||||||
|
|
||||||
|
prefsGroup.MapGet("", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||||
|
if (pref == null)
|
||||||
|
{
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
db.NotificationPreferences.Add(pref);
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
}
|
||||||
|
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||||
|
});
|
||||||
|
|
||||||
|
prefsGroup.MapPut("", async (NotificationPrefsUpdateDto body, HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||||
|
{
|
||||||
|
var userId = GetUserId(http);
|
||||||
|
if (userId == Guid.Empty) return Results.Unauthorized();
|
||||||
|
var pref = await db.NotificationPreferences.FirstOrDefaultAsync(p => p.UserId == userId, ct);
|
||||||
|
if (pref == null)
|
||||||
|
{
|
||||||
|
pref = new NotificationPreference { Id = Guid.NewGuid(), UserId = userId };
|
||||||
|
db.NotificationPreferences.Add(pref);
|
||||||
|
}
|
||||||
|
if (body.MedicationReminder.HasValue) pref.MedicationReminder = body.MedicationReminder.Value;
|
||||||
|
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
|
||||||
|
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
|
||||||
|
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
|
||||||
|
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
|
||||||
|
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
|
||||||
|
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
|
||||||
|
if (body.HealthRecordReminderGlucose.HasValue) pref.HealthRecordReminderGlucose = body.HealthRecordReminderGlucose.Value;
|
||||||
|
if (body.HealthRecordReminderSpO2.HasValue) pref.HealthRecordReminderSpO2 = body.HealthRecordReminderSpO2.Value;
|
||||||
|
if (body.HealthRecordReminderWeight.HasValue) pref.HealthRecordReminderWeight = body.HealthRecordReminderWeight.Value;
|
||||||
|
pref.UpdatedAt = DateTime.UtcNow;
|
||||||
|
await db.SaveChangesAsync(ct);
|
||||||
|
return Results.Ok(new { code = 0, data = ToDto(pref), message = (string?)null });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Guid GetUserId(HttpContext http) =>
|
private static Guid GetUserId(HttpContext http) =>
|
||||||
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
Guid.TryParse(http.User.FindFirst(ClaimTypes.NameIdentifier)?.Value, out var id) ? id : Guid.Empty;
|
||||||
|
|
||||||
|
private static object ToDto(NotificationPreference pref) => new
|
||||||
|
{
|
||||||
|
medicationReminder = pref.MedicationReminder,
|
||||||
|
followUpReminder = pref.FollowUpReminder,
|
||||||
|
doctorReply = pref.DoctorReply,
|
||||||
|
abnormalAlert = pref.AbnormalAlert,
|
||||||
|
healthRecordReminder = pref.HealthRecordReminder,
|
||||||
|
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
|
||||||
|
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
|
||||||
|
healthRecordReminderGlucose = pref.HealthRecordReminderGlucose,
|
||||||
|
healthRecordReminderSpO2 = pref.HealthRecordReminderSpO2,
|
||||||
|
healthRecordReminderWeight = pref.HealthRecordReminderWeight,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public sealed record NotificationPrefsUpdateDto(
|
||||||
|
bool? MedicationReminder,
|
||||||
|
bool? FollowUpReminder,
|
||||||
|
bool? DoctorReply,
|
||||||
|
bool? AbnormalAlert,
|
||||||
|
bool? HealthRecordReminder,
|
||||||
|
bool? HealthRecordReminderBloodPressure,
|
||||||
|
bool? HealthRecordReminderHeartRate,
|
||||||
|
bool? HealthRecordReminderGlucose,
|
||||||
|
bool? HealthRecordReminderSpO2,
|
||||||
|
bool? HealthRecordReminderWeight
|
||||||
|
);
|
||||||
|
|||||||
@@ -172,6 +172,8 @@ builder.Services.AddHttpClient<FastGptKnowledgeClient>(client =>
|
|||||||
// ---- 后台服务 ----
|
// ---- 后台服务 ----
|
||||||
builder.Services.AddHostedService<MedicationReminderService>();
|
builder.Services.AddHostedService<MedicationReminderService>();
|
||||||
builder.Services.AddHostedService<ExerciseReminderService>();
|
builder.Services.AddHostedService<ExerciseReminderService>();
|
||||||
|
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||||
|
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||||
builder.Services.AddHostedService<MedicationReminderWorker>();
|
builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||||
builder.Services.AddHostedService<CleanupService>();
|
builder.Services.AddHostedService<CleanupService>();
|
||||||
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||||
|
|||||||
@@ -4,11 +4,9 @@ import 'package:dio/dio.dart';
|
|||||||
import 'local_database.dart';
|
import 'local_database.dart';
|
||||||
|
|
||||||
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
|
||||||
/// Android 真机 USB: 先执行 adb reverse tcp:5000 tcp:5000 然后 localhost 即可
|
|
||||||
/// Android 真机 WiFi: flutter run --dart-define=API_BASE_URL=http://电脑IP:5000
|
|
||||||
const String baseUrl = String.fromEnvironment(
|
const String baseUrl = String.fromEnvironment(
|
||||||
'API_BASE_URL',
|
'API_BASE_URL',
|
||||||
defaultValue: 'http://10.4.170.202:5000',
|
defaultValue: 'http://10.4.191.129:5000',
|
||||||
);
|
);
|
||||||
|
|
||||||
class ApiException implements Exception {
|
class ApiException implements Exception {
|
||||||
|
|||||||
@@ -361,10 +361,8 @@ class AppBackground extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final content = safeArea ? SafeArea(child: child) : child;
|
final content = safeArea ? SafeArea(child: child) : child;
|
||||||
|
|
||||||
return DecoratedBox(
|
// 全局浅灰白背景,已弃用紫色渐变。
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
|
||||||
child: content,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -387,16 +385,15 @@ class GradientScaffold extends StatelessWidget {
|
|||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => AppBackground(
|
Widget build(BuildContext context) => Scaffold(
|
||||||
child: Scaffold(
|
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。
|
||||||
backgroundColor: Colors.transparent,
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
appBar: appBar,
|
appBar: appBar,
|
||||||
body: body,
|
body: body,
|
||||||
floatingActionButton: floatingActionButton,
|
floatingActionButton: floatingActionButton,
|
||||||
drawer: drawer,
|
drawer: drawer,
|
||||||
bottomNavigationBar: bottomNavigationBar,
|
bottomNavigationBar: bottomNavigationBar,
|
||||||
extendBody: extendBody,
|
extendBody: extendBody,
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -563,6 +560,12 @@ class AppTheme {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// TextButton 全局默认黑色文字,避免出现浅紫色按钮。
|
||||||
|
// 需要红色(如删除)时仍可在使用处单独 styleFrom(foregroundColor: AppColors.error) 覆盖。
|
||||||
|
textButtonTheme: TextButtonThemeData(
|
||||||
|
style: TextButton.styleFrom(foregroundColor: text),
|
||||||
|
),
|
||||||
|
|
||||||
dialogTheme: DialogThemeData(
|
dialogTheme: DialogThemeData(
|
||||||
backgroundColor: surface,
|
backgroundColor: surface,
|
||||||
surfaceTintColor: Colors.transparent,
|
surfaceTintColor: Colors.transparent,
|
||||||
|
|||||||
@@ -360,10 +360,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
final state = ref.watch(dietProvider);
|
final state = ref.watch(dietProvider);
|
||||||
final screenW = MediaQuery.of(context).size.width;
|
final screenW = MediaQuery.of(context).size.width;
|
||||||
|
|
||||||
return Container(
|
return SingleChildScrollView(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||||
child: SingleChildScrollView(
|
|
||||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 图片自适应显示
|
// 图片自适应显示
|
||||||
@@ -406,7 +404,6 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
|||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1484,39 +1484,44 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
boText == null &&
|
boText == null &&
|
||||||
wtText == null;
|
wtText == null;
|
||||||
|
|
||||||
// 异常检测
|
// ── 多指标异常检测 ──
|
||||||
final bpAbnormal =
|
final abnormals = <String>[];
|
||||||
bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140;
|
if (bp is Map && bp['systolic'] is int) {
|
||||||
final hasAbnormal = bpAbnormal;
|
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 偏高');
|
||||||
|
}
|
||||||
|
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)} 偏慢');
|
||||||
|
}
|
||||||
|
if (bs is Map && bs['value'] is num) {
|
||||||
|
final v = (bs['value'] as num).toDouble();
|
||||||
|
if (v > 6.1) 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 hasAbnormal = abnormals.isNotEmpty;
|
||||||
|
|
||||||
// ── 1. 健康指标 ──
|
// ── 1. 健康指标 ──
|
||||||
|
// 没记录则整行不显示(避免每天唠叨提醒)
|
||||||
const healthIconColor = Color(0xFF3B82F6);
|
const healthIconColor = Color(0xFF3B82F6);
|
||||||
const healthIconBg = Color(0xFFDBEAFE);
|
const healthIconBg = Color(0xFFDBEAFE);
|
||||||
if (allNull) {
|
if (allNull) {
|
||||||
tasks.add(
|
// 不显示
|
||||||
_taskRow(
|
|
||||||
context,
|
|
||||||
Icons.monitor_heart_outlined,
|
|
||||||
'健康指标',
|
|
||||||
trailing: '今日暂无记录,点击录入',
|
|
||||||
status: 'pending',
|
|
||||||
iconColor: healthIconColor,
|
|
||||||
iconBg: healthIconBg,
|
|
||||||
onTap: () => pushRoute(ref, 'trend'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
} else if (hasAbnormal) {
|
} else if (hasAbnormal) {
|
||||||
// 有异常时显示异常指标
|
final trailing = abnormals.length == 1
|
||||||
final abnormalParts = <String>[];
|
? abnormals.first
|
||||||
if (bpAbnormal) {
|
: '${abnormals.length} 项指标异常';
|
||||||
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
|
|
||||||
}
|
|
||||||
tasks.add(
|
tasks.add(
|
||||||
_taskRow(
|
_taskRow(
|
||||||
context,
|
context,
|
||||||
Icons.warning_amber_rounded,
|
Icons.warning_amber_rounded,
|
||||||
'健康指标',
|
'健康指标',
|
||||||
trailing: abnormalParts.join(' · '),
|
trailing: trailing,
|
||||||
status: 'warning',
|
status: 'warning',
|
||||||
iconColor: healthIconColor,
|
iconColor: healthIconColor,
|
||||||
iconBg: healthIconBg,
|
iconBg: healthIconBg,
|
||||||
@@ -1626,72 +1631,74 @@ class ChatMessagesView extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── 3. 用药打卡 ──
|
// ── 3. 用药打卡 ──
|
||||||
|
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
||||||
const medIconColor = Color(0xFFEC4899);
|
const medIconColor = Color(0xFFEC4899);
|
||||||
const medIconBg = Color(0xFFFCE7F3);
|
const medIconBg = Color(0xFFFCE7F3);
|
||||||
reminders.whenOrNull(
|
reminders.whenOrNull(
|
||||||
data: (meds) {
|
data: (meds) {
|
||||||
if (meds.isNotEmpty) {
|
final overdueMeds = meds
|
||||||
final overdueMeds = meds
|
.where((m) => m['status'] == 'overdue')
|
||||||
.where((m) => m['status'] == 'overdue')
|
.toList();
|
||||||
.toList();
|
if (overdueMeds.isEmpty) return;
|
||||||
final upcomingMeds = meds
|
|
||||||
.where((m) => m['status'] == 'upcoming')
|
// 按过期时长排序——先把最严重的放前面
|
||||||
.toList();
|
final now = DateTime.now();
|
||||||
final takenMeds = meds.where((m) => m['status'] == 'taken').toList();
|
final withDelta = overdueMeds.map((m) {
|
||||||
// 未服/过期的在前面
|
final timeStr = m['scheduledTime']?.toString() ?? '';
|
||||||
for (final m in [...overdueMeds, ...upcomingMeds]) {
|
final parts = timeStr.split(':');
|
||||||
final name = m['name']?.toString() ?? '药品';
|
DateTime? scheduled;
|
||||||
final time = m['scheduledTime']?.toString() ?? '';
|
if (parts.length >= 2) {
|
||||||
final isOverdue = m['status'] == 'overdue';
|
final h = int.tryParse(parts[0]);
|
||||||
tasks.add(
|
final mn = int.tryParse(parts[1]);
|
||||||
_taskRow(
|
if (h != null && mn != null) {
|
||||||
context,
|
scheduled = DateTime(now.year, now.month, now.day, h, mn);
|
||||||
Icons.medication_rounded,
|
}
|
||||||
name,
|
|
||||||
trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time',
|
|
||||||
status: isOverdue ? 'overdue' : 'pending',
|
|
||||||
iconColor: medIconColor,
|
|
||||||
iconBg: medIconBg,
|
|
||||||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// 已服的合并显示
|
|
||||||
if (takenMeds.isNotEmpty) {
|
|
||||||
final names = takenMeds
|
|
||||||
.map((m) => m['name']?.toString() ?? '药品')
|
|
||||||
.join('、');
|
|
||||||
tasks.add(
|
|
||||||
_taskRow(
|
|
||||||
context,
|
|
||||||
Icons.check_circle,
|
|
||||||
names,
|
|
||||||
trailing: '已服用 (${takenMeds.length}项)',
|
|
||||||
status: 'done',
|
|
||||||
iconColor: medIconColor,
|
|
||||||
iconBg: medIconBg,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
final overdueHours = scheduled == null
|
||||||
|
? 0
|
||||||
|
: now.difference(scheduled).inMinutes / 60.0;
|
||||||
|
return (m, overdueHours);
|
||||||
|
}).toList()
|
||||||
|
..sort((a, b) => b.$2.compareTo(a.$2));
|
||||||
|
|
||||||
|
// 取最严重那一项作为代表,其他用"等"省略
|
||||||
|
final first = withDelta.first;
|
||||||
|
final firstName = first.$1['name']?.toString() ?? '药品';
|
||||||
|
final firstTime = first.$1['scheduledTime']?.toString() ?? '';
|
||||||
|
final firstHours = first.$2;
|
||||||
|
|
||||||
|
// 过期 < 1h 提醒服药;1-4h 可补服(红色);>4h 已错过窗口(灰色提示但不强警告)
|
||||||
|
final String trailing;
|
||||||
|
final String status;
|
||||||
|
if (firstHours < 1) {
|
||||||
|
trailing = '$firstTime 该服药了';
|
||||||
|
status = 'pending';
|
||||||
|
} else if (firstHours < 4) {
|
||||||
|
trailing = '$firstTime 已漏服 ${firstHours.toStringAsFixed(0)} 小时,可补服';
|
||||||
|
status = 'overdue';
|
||||||
|
} else {
|
||||||
|
trailing = '$firstTime 已错过,请等下次按时服用';
|
||||||
|
status = 'overdue';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
final title = overdueMeds.length == 1
|
||||||
|
? firstName
|
||||||
|
: '$firstName 等 ${overdueMeds.length} 种';
|
||||||
|
|
||||||
|
tasks.add(
|
||||||
|
_taskRow(
|
||||||
|
context,
|
||||||
|
Icons.medication_rounded,
|
||||||
|
title,
|
||||||
|
trailing: trailing,
|
||||||
|
status: status,
|
||||||
|
iconColor: medIconColor,
|
||||||
|
iconBg: medIconBg,
|
||||||
|
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||||
|
),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
final hasMeds =
|
|
||||||
reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty;
|
|
||||||
if (!hasMeds) {
|
|
||||||
tasks.add(
|
|
||||||
_taskRow(
|
|
||||||
context,
|
|
||||||
Icons.medication_rounded,
|
|
||||||
'用药打卡',
|
|
||||||
trailing: '暂无用药提醒',
|
|
||||||
status: 'pending',
|
|
||||||
iconColor: medIconColor,
|
|
||||||
iconBg: medIconBg,
|
|
||||||
onTap: () => pushRoute(ref, 'medications'),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 12),
|
margin: const EdgeInsets.only(bottom: 12),
|
||||||
|
|||||||
@@ -67,8 +67,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
|||||||
preferredSize: const Size.fromHeight(48),
|
preferredSize: const Size.fromHeight(48),
|
||||||
child: TabBar(
|
child: TabBar(
|
||||||
controller: _tabCtrl,
|
controller: _tabCtrl,
|
||||||
indicatorColor: AppColors.primary,
|
indicatorColor: AppColors.textPrimary,
|
||||||
labelColor: AppColors.primary,
|
labelColor: AppColors.textPrimary,
|
||||||
unselectedLabelColor: AppColors.textHint,
|
unselectedLabelColor: AppColors.textHint,
|
||||||
labelStyle: const TextStyle(
|
labelStyle: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
@@ -84,9 +84,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
|||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
|
||||||
child: AppFutureView<List<Map<String, dynamic>>>(
|
|
||||||
future: _future,
|
future: _future,
|
||||||
onRetry: _load,
|
onRetry: _load,
|
||||||
errorTitle: '用药信息加载失败',
|
errorTitle: '用药信息加载失败',
|
||||||
@@ -121,10 +119,15 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
|||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: AppColors.surfaceGradient,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||||
border: Border.all(color: AppColors.borderLight),
|
boxShadow: [
|
||||||
boxShadow: AppColors.cardShadowLight,
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
@@ -207,7 +210,6 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -144,31 +144,73 @@ class _NotificationCenterPageState
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final items = _history?.items ?? const <InAppNotification>[];
|
final items = _history?.items ?? const <InAppNotification>[];
|
||||||
|
final unread = _history?.unreadCount ?? 0;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: AppBackground(
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
safeArea: true,
|
appBar: AppBar(
|
||||||
child: Column(
|
backgroundColor: Colors.white,
|
||||||
children: [
|
elevation: 0,
|
||||||
_Header(
|
scrolledUnderElevation: 0.5,
|
||||||
unreadCount: _history?.unreadCount ?? 0,
|
surfaceTintColor: Colors.transparent,
|
||||||
markingAll: _markingAll,
|
leading: IconButton(
|
||||||
onBack: () => popRoute(ref),
|
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||||
onMarkAllRead: _markAllRead,
|
onPressed: () => popRoute(ref),
|
||||||
),
|
),
|
||||||
Expanded(
|
title: const Text(
|
||||||
child: RefreshIndicator(
|
'通知中心',
|
||||||
onRefresh: _load,
|
style: TextStyle(
|
||||||
color: const Color(0xFF438CE1),
|
fontSize: 19,
|
||||||
child: _buildBody(items),
|
fontWeight: FontWeight.w700,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
centerTitle: true,
|
||||||
|
actions: [
|
||||||
|
if (unread > 0)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(right: 4),
|
||||||
|
child: TextButton(
|
||||||
|
onPressed: _markingAll ? null : _markAllRead,
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: AppColors.textPrimary,
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||||
|
),
|
||||||
|
child: _markingAll
|
||||||
|
? const SizedBox(
|
||||||
|
width: 16,
|
||||||
|
height: 16,
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
strokeWidth: 2,
|
||||||
|
color: AppColors.textPrimary,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const Text(
|
||||||
|
'全部已读',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
IconButton(
|
||||||
),
|
tooltip: '通知设置',
|
||||||
|
icon: const Icon(Icons.settings_outlined, color: AppColors.textPrimary),
|
||||||
|
onPressed: () => pushRoute(ref, 'notificationPrefs'),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: RefreshIndicator(
|
||||||
|
onRefresh: _load,
|
||||||
|
color: const Color(0xFF6366F1),
|
||||||
|
child: _buildBody(items, unread),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildBody(List<InAppNotification> items) {
|
Widget _buildBody(List<InAppNotification> items, int unread) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
}
|
}
|
||||||
@@ -203,15 +245,19 @@ class _NotificationCenterPageState
|
|||||||
|
|
||||||
return ListView(
|
return ListView(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||||
children: [
|
children: [
|
||||||
|
if (unread > 0) _UnreadHint(unreadCount: unread),
|
||||||
|
const SizedBox(height: 4),
|
||||||
if (today.isNotEmpty) ...[
|
if (today.isNotEmpty) ...[
|
||||||
const _SectionTitle('今天'),
|
const _SectionTitle('今天'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
...today.map(_buildDismissible),
|
...today.map(_buildDismissible),
|
||||||
],
|
],
|
||||||
if (earlier.isNotEmpty) ...[
|
if (earlier.isNotEmpty) ...[
|
||||||
const SizedBox(height: 10),
|
if (today.isNotEmpty) const SizedBox(height: 12),
|
||||||
const _SectionTitle('更早'),
|
const _SectionTitle('更早'),
|
||||||
|
const SizedBox(height: 4),
|
||||||
...earlier.map(_buildDismissible),
|
...earlier.map(_buildDismissible),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -219,18 +265,39 @@ class _NotificationCenterPageState
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDismissible(InAppNotification item) => Padding(
|
Widget _buildDismissible(InAppNotification item) => Padding(
|
||||||
padding: const EdgeInsets.only(bottom: 10),
|
padding: const EdgeInsets.only(bottom: 12),
|
||||||
child: Dismissible(
|
child: Dismissible(
|
||||||
key: ValueKey(item.id),
|
key: ValueKey(item.id),
|
||||||
direction: DismissDirection.endToStart,
|
direction: DismissDirection.endToStart,
|
||||||
background: Container(
|
background: Container(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
padding: const EdgeInsets.only(right: 24),
|
padding: const EdgeInsets.only(right: 26),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: AppColors.error,
|
gradient: LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(18),
|
begin: Alignment.centerLeft,
|
||||||
|
end: Alignment.centerRight,
|
||||||
|
colors: [
|
||||||
|
AppColors.error.withValues(alpha: 0.6),
|
||||||
|
AppColors.error,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
),
|
||||||
|
child: const Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(LucideIcons.trash2, color: Colors.white, size: 22),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'删除',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 15,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(LucideIcons.trash2, color: Colors.white),
|
|
||||||
),
|
),
|
||||||
onDismissed: (_) => _delete(item),
|
onDismissed: (_) => _delete(item),
|
||||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||||
@@ -238,68 +305,38 @@ class _NotificationCenterPageState
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _Header extends StatelessWidget {
|
/// 顶部一行精致提示——只在有未读时显示,不抢眼
|
||||||
|
class _UnreadHint extends StatelessWidget {
|
||||||
final int unreadCount;
|
final int unreadCount;
|
||||||
final bool markingAll;
|
const _UnreadHint({required this.unreadCount});
|
||||||
final VoidCallback onBack;
|
|
||||||
final VoidCallback onMarkAllRead;
|
|
||||||
|
|
||||||
const _Header({
|
|
||||||
required this.unreadCount,
|
|
||||||
required this.markingAll,
|
|
||||||
required this.onBack,
|
|
||||||
required this.onMarkAllRead,
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) {
|
||||||
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
|
return Padding(
|
||||||
child: Row(
|
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||||
children: [
|
child: Row(
|
||||||
IconButton(
|
children: [
|
||||||
tooltip: '返回',
|
Container(
|
||||||
onPressed: onBack,
|
width: 6,
|
||||||
icon: const Icon(LucideIcons.chevronLeft),
|
height: 6,
|
||||||
),
|
decoration: const BoxDecoration(
|
||||||
const SizedBox(width: 2),
|
color: Color(0xFFEF4444),
|
||||||
Expanded(
|
shape: BoxShape.circle,
|
||||||
child: Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
const Text(
|
|
||||||
'通知中心',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 21,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 2),
|
|
||||||
Text(
|
|
||||||
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
const SizedBox(width: 8),
|
||||||
if (unreadCount > 0)
|
Text(
|
||||||
TextButton.icon(
|
'你有 $unreadCount 条未读消息',
|
||||||
onPressed: markingAll ? null : onMarkAllRead,
|
style: const TextStyle(
|
||||||
icon: markingAll
|
fontSize: 13,
|
||||||
? const SizedBox(
|
fontWeight: FontWeight.w600,
|
||||||
width: 14,
|
color: AppColors.textSecondary,
|
||||||
height: 14,
|
),
|
||||||
child: CircularProgressIndicator(strokeWidth: 2),
|
|
||||||
)
|
|
||||||
: const Icon(LucideIcons.checkCheck, size: 17),
|
|
||||||
label: const Text('全部已读'),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
class _SectionTitle extends StatelessWidget {
|
||||||
@@ -308,13 +345,14 @@ class _SectionTitle extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => Padding(
|
Widget build(BuildContext context) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
|
padding: const EdgeInsets.fromLTRB(4, 12, 4, 6),
|
||||||
child: Text(
|
child: Text(
|
||||||
text,
|
text,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w700,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textSecondary,
|
color: AppColors.textHint,
|
||||||
|
letterSpacing: 0.3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@@ -330,89 +368,85 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final visual = _NotificationVisual.of(item);
|
final visual = _NotificationVisual.of(item);
|
||||||
return Material(
|
return Material(
|
||||||
color: item.isRead
|
color: Colors.white,
|
||||||
? Colors.white.withValues(alpha: 0.72)
|
borderRadius: BorderRadius.circular(14),
|
||||||
: Colors.white.withValues(alpha: 0.96),
|
|
||||||
borderRadius: BorderRadius.circular(18),
|
|
||||||
elevation: item.isRead ? 0 : 1,
|
|
||||||
shadowColor: visual.color.withValues(alpha: 0.12),
|
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(18),
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(
|
border: Border.all(
|
||||||
color: item.isRead
|
color: item.isRead
|
||||||
? const Color(0xFFE9EEF5)
|
? const Color(0xFFEEF1F6)
|
||||||
: visual.color.withValues(alpha: 0.18),
|
: visual.color.withValues(alpha: 0.20),
|
||||||
),
|
),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.03),
|
||||||
|
blurRadius: 8,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
// 图标
|
||||||
width: 46,
|
Stack(
|
||||||
height: 46,
|
clipBehavior: Clip.none,
|
||||||
decoration: BoxDecoration(
|
children: [
|
||||||
gradient: LinearGradient(
|
Container(
|
||||||
begin: Alignment.topLeft,
|
width: 36,
|
||||||
end: Alignment.bottomRight,
|
height: 36,
|
||||||
colors: [visual.lightColor, Colors.white],
|
decoration: BoxDecoration(
|
||||||
|
color: visual.lightColor,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Icon(visual.icon, size: 18, color: visual.color),
|
||||||
),
|
),
|
||||||
borderRadius: BorderRadius.circular(14),
|
if (!item.isRead)
|
||||||
),
|
Positioned(
|
||||||
child: Icon(visual.icon, size: 22, color: visual.color),
|
top: -2,
|
||||||
|
right: -2,
|
||||||
|
child: Container(
|
||||||
|
width: 9,
|
||||||
|
height: 9,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFEF4444),
|
||||||
|
shape: BoxShape.circle,
|
||||||
|
border: Border.all(color: Colors.white, width: 1.5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 11),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
children: [
|
children: [
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
item.title,
|
item.title,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 14,
|
||||||
fontWeight: item.isRead
|
fontWeight: item.isRead
|
||||||
? FontWeight.w600
|
? FontWeight.w600
|
||||||
: FontWeight.w800,
|
: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
|
height: 1.3,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (!item.isRead)
|
const SizedBox(width: 6),
|
||||||
Container(
|
|
||||||
width: 8,
|
|
||||||
height: 8,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: visual.color,
|
|
||||||
shape: BoxShape.circle,
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: visual.color.withValues(alpha: 0.3),
|
|
||||||
blurRadius: 5,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
const SizedBox(height: 5),
|
|
||||||
Text(
|
|
||||||
item.message,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
height: 1.45,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(height: 9),
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
Text(
|
||||||
_formatTime(item.createdAt),
|
_formatTime(item.createdAt),
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
@@ -420,31 +454,30 @@ class _NotificationCard extends StatelessWidget {
|
|||||||
color: AppColors.textHint,
|
color: AppColors.textHint,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
|
||||||
if (item.actionType != null)
|
|
||||||
Row(
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'查看详情',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: visual.color,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 2),
|
|
||||||
Icon(
|
|
||||||
LucideIcons.chevronRight,
|
|
||||||
size: 14,
|
|
||||||
color: visual.color,
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
item.message,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
height: 1.45,
|
||||||
|
color: AppColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (item.actionType != null) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
const Icon(
|
||||||
|
Icons.chevron_right_rounded,
|
||||||
|
size: 18,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -472,48 +505,55 @@ class _NotificationVisual {
|
|||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
final Color lightColor;
|
final Color lightColor;
|
||||||
|
final String label;
|
||||||
|
|
||||||
const _NotificationVisual(this.icon, this.color, this.lightColor);
|
const _NotificationVisual(this.icon, this.color, this.lightColor, this.label);
|
||||||
|
|
||||||
factory _NotificationVisual.of(InAppNotification item) {
|
factory _NotificationVisual.of(InAppNotification item) {
|
||||||
switch (item.actionType) {
|
switch (item.actionType) {
|
||||||
case 'exercise':
|
case 'exercise':
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.activity,
|
LucideIcons.activity,
|
||||||
Color(0xFF2C9A70),
|
Color(0xFF10B981),
|
||||||
Color(0xFFDDF7EC),
|
Color(0xFFD1FAE5),
|
||||||
|
'运动',
|
||||||
);
|
);
|
||||||
case 'health':
|
case 'health':
|
||||||
if (item.severity == 'critical') {
|
if (item.severity == 'critical') {
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.triangleAlert,
|
LucideIcons.triangleAlert,
|
||||||
Color(0xFFE55353),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFFE7E7),
|
Color(0xFFFEE2E2),
|
||||||
|
'紧急',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.heartPulse,
|
LucideIcons.heartPulse,
|
||||||
Color(0xFFE8863A),
|
Color(0xFFF59E0B),
|
||||||
Color(0xFFFFEEDC),
|
Color(0xFFFEF3C7),
|
||||||
|
'健康',
|
||||||
);
|
);
|
||||||
case 'report':
|
case 'report':
|
||||||
if (item.severity == 'error') {
|
if (item.severity == 'error') {
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.fileWarning,
|
LucideIcons.fileWarning,
|
||||||
Color(0xFFE55353),
|
Color(0xFFEF4444),
|
||||||
Color(0xFFFFE7E7),
|
Color(0xFFFEE2E2),
|
||||||
|
'报告',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.fileCheck2,
|
LucideIcons.fileCheck2,
|
||||||
Color(0xFF7B68CE),
|
Color(0xFF8B5CF6),
|
||||||
Color(0xFFEDE9FF),
|
Color(0xFFEDE9FE),
|
||||||
|
'报告',
|
||||||
);
|
);
|
||||||
default:
|
default:
|
||||||
return const _NotificationVisual(
|
return const _NotificationVisual(
|
||||||
LucideIcons.pill,
|
LucideIcons.pill,
|
||||||
Color(0xFF468AD8),
|
Color(0xFF3B82F6),
|
||||||
Color(0xFFE4F0FF),
|
Color(0xFFDBEAFE),
|
||||||
|
'用药',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -540,38 +580,44 @@ class _MessageState extends StatelessWidget {
|
|||||||
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
|
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: LinearGradient(
|
color: Colors.white,
|
||||||
colors: [
|
|
||||||
Colors.white.withValues(alpha: 0.95),
|
|
||||||
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(24),
|
||||||
border: Border.all(color: const Color(0xFFE5EDF7)),
|
border: Border.all(color: const Color(0xFFE9EEF5)),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.04),
|
||||||
|
blurRadius: 14,
|
||||||
|
offset: const Offset(0, 4),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
width: 64,
|
width: 72,
|
||||||
height: 64,
|
height: 72,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFE5F0FF),
|
gradient: const LinearGradient(
|
||||||
borderRadius: BorderRadius.circular(20),
|
begin: Alignment.topLeft,
|
||||||
|
end: Alignment.bottomRight,
|
||||||
|
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(22),
|
||||||
),
|
),
|
||||||
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
|
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 18),
|
const SizedBox(height: 20),
|
||||||
Text(
|
Text(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 17,
|
fontSize: 18,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w800,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 7),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
message,
|
message,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
@@ -582,8 +628,18 @@ class _MessageState extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
if (buttonText != null) ...[
|
if (buttonText != null) ...[
|
||||||
const SizedBox(height: 20),
|
const SizedBox(height: 22),
|
||||||
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
|
FilledButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
backgroundColor: const Color(0xFF6366F1),
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 28,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Text(buttonText!),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -727,9 +727,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
backgroundColor: AppTheme.primary,
|
backgroundColor: AppTheme.primary,
|
||||||
child: const Icon(Icons.add),
|
child: const Icon(Icons.add),
|
||||||
),
|
),
|
||||||
body: Container(
|
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
|
||||||
child: AppFutureView<List<Map<String, dynamic>>>(
|
|
||||||
future: _future,
|
future: _future,
|
||||||
onRetry: _load,
|
onRetry: _load,
|
||||||
errorTitle: '运动计划加载失败',
|
errorTitle: '运动计划加载失败',
|
||||||
@@ -770,7 +768,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
borderRadius: BorderRadius.circular(AppTheme.rMd),
|
||||||
border: Border.all(color: const Color(0xFFE8ECF0)),
|
border: Border.all(color: const Color(0xFFCFD6E0), width: 1.2),
|
||||||
boxShadow: [AppTheme.shadowLight],
|
boxShadow: [AppTheme.shadowLight],
|
||||||
),
|
),
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -854,7 +852,6 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1399,7 +1396,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
if (_loading) {
|
if (_loading) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -1417,7 +1414,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
@@ -1442,6 +1439,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
|||||||
)
|
)
|
||||||
: TextButton(
|
: TextButton(
|
||||||
onPressed: _save,
|
onPressed: _save,
|
||||||
|
style: TextButton.styleFrom(foregroundColor: AppColors.textPrimary),
|
||||||
child: const Text('保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
child: const Text('保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@@ -1550,8 +1548,14 @@ class _Section extends StatelessWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(14),
|
borderRadius: BorderRadius.circular(14),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFD5DCE5)),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
|||||||
@@ -546,8 +546,14 @@ class ReportListPage extends ConsumerWidget {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(20),
|
borderRadius: BorderRadius.circular(20),
|
||||||
border: Border.all(color: AppColors.border),
|
border: Border.all(color: const Color(0xFFD5DCE5)),
|
||||||
boxShadow: AppColors.cardShadowLight,
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||||
|
blurRadius: 18,
|
||||||
|
offset: const Offset(0, 6),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
child: Material(
|
child: Material(
|
||||||
color: Colors.transparent,
|
color: Colors.transparent,
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ import '../../core/app_colors.dart';
|
|||||||
import '../../core/app_theme.dart';
|
import '../../core/app_theme.dart';
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||||
import '../../core/navigation_provider.dart';
|
import '../../core/navigation_provider.dart';
|
||||||
|
import '../../providers/auth_provider.dart';
|
||||||
|
|
||||||
// ── 通知偏好状态 ──
|
// ── 通知偏好状态 ──
|
||||||
|
// 持久化到后端 /api/notification-prefs,启动时自动拉取一次。
|
||||||
|
|
||||||
final notificationPrefsProvider =
|
final notificationPrefsProvider =
|
||||||
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||||
@@ -12,28 +14,92 @@ final notificationPrefsProvider =
|
|||||||
);
|
);
|
||||||
|
|
||||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||||
|
static const _defaults = {
|
||||||
|
'pushEnabled': true,
|
||||||
|
'medication': true,
|
||||||
|
'healthAlert': true,
|
||||||
|
'followUp': true,
|
||||||
|
'aiReply': false,
|
||||||
|
'dndEnabled': false,
|
||||||
|
'dndStart': false,
|
||||||
|
'dndEnd': false,
|
||||||
|
// 健康录入提醒——总开关 + 5 子项
|
||||||
|
'healthRecord': true,
|
||||||
|
'healthRecord.bp': true,
|
||||||
|
'healthRecord.hr': true,
|
||||||
|
'healthRecord.glucose': true,
|
||||||
|
'healthRecord.spo2': true,
|
||||||
|
'healthRecord.weight': true,
|
||||||
|
};
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, bool> build() {
|
Map<String, bool> build() {
|
||||||
// TODO: 从 SQLite 读取持久化值,此处先用默认值
|
Future.microtask(_loadFromBackend);
|
||||||
return {
|
return {..._defaults};
|
||||||
'medication': true,
|
|
||||||
'healthAlert': true,
|
|
||||||
'followUp': true,
|
|
||||||
'aiReply': false,
|
|
||||||
'dndEnabled': false,
|
|
||||||
'pushEnabled': true,
|
|
||||||
'dndStart': false, // 占位:实际用 TimeOfDay
|
|
||||||
'dndEnd': false,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void toggle(String key) {
|
Future<void> _loadFromBackend() async {
|
||||||
state = {...state, key: !state[key]!};
|
try {
|
||||||
// TODO: 持久化到 SQLite
|
final api = ref.read(apiClientProvider);
|
||||||
|
final res = await api.get('/api/notification-prefs');
|
||||||
|
final data = res.data['data'] as Map?;
|
||||||
|
if (data == null) return;
|
||||||
|
state = {
|
||||||
|
...state,
|
||||||
|
'medication': (data['medicationReminder'] as bool?) ?? true,
|
||||||
|
'healthAlert': (data['abnormalAlert'] as bool?) ?? true,
|
||||||
|
'followUp': (data['followUpReminder'] as bool?) ?? true,
|
||||||
|
'aiReply': (data['doctorReply'] as bool?) ?? false,
|
||||||
|
'healthRecord': (data['healthRecordReminder'] as bool?) ?? true,
|
||||||
|
'healthRecord.bp':
|
||||||
|
(data['healthRecordReminderBloodPressure'] as bool?) ?? true,
|
||||||
|
'healthRecord.hr':
|
||||||
|
(data['healthRecordReminderHeartRate'] as bool?) ?? true,
|
||||||
|
'healthRecord.glucose':
|
||||||
|
(data['healthRecordReminderGlucose'] as bool?) ?? true,
|
||||||
|
'healthRecord.spo2':
|
||||||
|
(data['healthRecordReminderSpO2'] as bool?) ?? true,
|
||||||
|
'healthRecord.weight':
|
||||||
|
(data['healthRecordReminderWeight'] as bool?) ?? true,
|
||||||
|
};
|
||||||
|
} catch (_) {
|
||||||
|
// 网络异常时保留默认值
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> toggle(String key) async {
|
||||||
|
final newValue = !(state[key] ?? false);
|
||||||
|
state = {...state, key: newValue};
|
||||||
|
await _pushToBackend(key, newValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _pushToBackend(String key, bool value) async {
|
||||||
|
// 把前端 key 映射到后端字段名
|
||||||
|
String? backendField = switch (key) {
|
||||||
|
'medication' => 'medicationReminder',
|
||||||
|
'healthAlert' => 'abnormalAlert',
|
||||||
|
'followUp' => 'followUpReminder',
|
||||||
|
'aiReply' => 'doctorReply',
|
||||||
|
'healthRecord' => 'healthRecordReminder',
|
||||||
|
'healthRecord.bp' => 'healthRecordReminderBloodPressure',
|
||||||
|
'healthRecord.hr' => 'healthRecordReminderHeartRate',
|
||||||
|
'healthRecord.glucose' => 'healthRecordReminderGlucose',
|
||||||
|
'healthRecord.spo2' => 'healthRecordReminderSpO2',
|
||||||
|
'healthRecord.weight' => 'healthRecordReminderWeight',
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
if (backendField == null) return;
|
||||||
|
try {
|
||||||
|
final api = ref.read(apiClientProvider);
|
||||||
|
await api.put('/api/notification-prefs', data: {backendField: value});
|
||||||
|
} catch (_) {
|
||||||
|
// 失败时回滚(避免界面与后端不同步)
|
||||||
|
state = {...state, key: !value};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDndStart(TimeOfDay time) {
|
void setDndStart(TimeOfDay time) {
|
||||||
state = {...state, 'dndStart': true}; // 简化存储
|
state = {...state, 'dndStart': true};
|
||||||
}
|
}
|
||||||
|
|
||||||
void setDndEnd(TimeOfDay time) {
|
void setDndEnd(TimeOfDay time) {
|
||||||
@@ -137,6 +203,58 @@ class NotificationPrefsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
|
// ── 健康录入提醒 ──
|
||||||
|
_SectionTitle(title: '健康录入提醒'),
|
||||||
|
_SwitchTile(
|
||||||
|
icon: Icons.alarm_on_rounded,
|
||||||
|
iconBg: const Color(0xFFFEF3C7),
|
||||||
|
iconColor: const Color(0xFFD97706),
|
||||||
|
title: '每日录入提醒',
|
||||||
|
subtitle: '每天上午提醒录入健康指标,已录入则不再打扰',
|
||||||
|
value: prefs['healthRecord'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord'),
|
||||||
|
),
|
||||||
|
if (prefs['healthRecord'] ?? true) ...[
|
||||||
|
_SwitchTile(
|
||||||
|
title: ' · 血压',
|
||||||
|
value: prefs['healthRecord.bp'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord.bp'),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: ' · 心率',
|
||||||
|
value: prefs['healthRecord.hr'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord.hr'),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: ' · 血糖',
|
||||||
|
value: prefs['healthRecord.glucose'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord.glucose'),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: ' · 血氧',
|
||||||
|
value: prefs['healthRecord.spo2'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord.spo2'),
|
||||||
|
),
|
||||||
|
_SwitchTile(
|
||||||
|
title: ' · 体重',
|
||||||
|
value: prefs['healthRecord.weight'] ?? true,
|
||||||
|
onChanged: (v) => ref
|
||||||
|
.read(notificationPrefsProvider.notifier)
|
||||||
|
.toggle('healthRecord.weight'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ── 免打扰时段 ──
|
// ── 免打扰时段 ──
|
||||||
_SectionTitle(title: '免打扰时段'),
|
_SectionTitle(title: '免打扰时段'),
|
||||||
_SwitchTile(
|
_SwitchTile(
|
||||||
|
|||||||
@@ -11,12 +11,16 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
return GradientScaffold(
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFFF6F8FC),
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
backgroundColor: Colors.white.withValues(alpha: 0.86),
|
backgroundColor: Colors.white,
|
||||||
|
elevation: 0,
|
||||||
|
scrolledUnderElevation: 0.5,
|
||||||
|
surfaceTintColor: Colors.transparent,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
LucideIcons.chevronLeft,
|
Icons.arrow_back,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
onPressed: () => popRoute(ref),
|
onPressed: () => popRoute(ref),
|
||||||
@@ -24,8 +28,8 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
title: const Text(
|
title: const Text(
|
||||||
'设置',
|
'设置',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: 19,
|
||||||
fontWeight: FontWeight.w800,
|
fontWeight: FontWeight.w700,
|
||||||
color: AppColors.textPrimary,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -33,72 +37,58 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
body: SafeArea(
|
body: SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34),
|
padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
children: [
|
children: [
|
||||||
const _SettingsHeader(),
|
_SettingsTile(
|
||||||
const SizedBox(height: 18),
|
icon: LucideIcons.bluetooth,
|
||||||
_SettingsGroup(
|
title: '蓝牙设备',
|
||||||
title: '健康与设备',
|
colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)],
|
||||||
children: [
|
onTap: () => pushRoute(ref, 'devices'),
|
||||||
_SettingsTile(
|
|
||||||
icon: LucideIcons.bluetooth,
|
|
||||||
title: '蓝牙设备',
|
|
||||||
colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)],
|
|
||||||
onTap: () => pushRoute(ref, 'devices'),
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
|
||||||
icon: LucideIcons.bell,
|
|
||||||
title: '消息通知',
|
|
||||||
colors: const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
|
||||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
_SettingsGroup(
|
_SettingsTile(
|
||||||
title: '使用偏好',
|
icon: LucideIcons.bell,
|
||||||
children: [
|
title: '消息通知',
|
||||||
_SettingsTile(
|
colors: const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
||||||
icon: LucideIcons.type,
|
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||||
title: '字体大小',
|
|
||||||
subtitle: '老年友好',
|
|
||||||
colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)],
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
|
||||||
icon: LucideIcons.sprayCan,
|
|
||||||
title: '清除缓存',
|
|
||||||
colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)],
|
|
||||||
onTap: () {},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 8),
|
||||||
_SettingsGroup(
|
_SettingsTile(
|
||||||
title: '关于',
|
icon: LucideIcons.type,
|
||||||
children: [
|
title: '字体大小',
|
||||||
_SettingsTile(
|
subtitle: '老年友好',
|
||||||
icon: LucideIcons.info,
|
colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)],
|
||||||
title: '关于小脉健康',
|
onTap: () {},
|
||||||
colors: const [Color(0xFF60A5FA), Color(0xFF0891B2)],
|
|
||||||
onTap: () =>
|
|
||||||
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
|
||||||
),
|
|
||||||
_SettingsTile(
|
|
||||||
icon: LucideIcons.shield,
|
|
||||||
title: '隐私协议',
|
|
||||||
colors: const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
|
||||||
onTap: () => pushRoute(
|
|
||||||
ref,
|
|
||||||
'staticText',
|
|
||||||
params: {'type': 'privacy'},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 8),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: LucideIcons.sprayCan,
|
||||||
|
title: '清除缓存',
|
||||||
|
colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)],
|
||||||
|
onTap: () {},
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: LucideIcons.info,
|
||||||
|
title: '关于小脉健康',
|
||||||
|
colors: const [Color(0xFF60A5FA), Color(0xFF0891B2)],
|
||||||
|
onTap: () =>
|
||||||
|
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
_SettingsTile(
|
||||||
|
icon: LucideIcons.shield,
|
||||||
|
title: '隐私协议',
|
||||||
|
colors: const [Color(0xFFF472B6), Color(0xFFDB2777)],
|
||||||
|
onTap: () => pushRoute(
|
||||||
|
ref,
|
||||||
|
'staticText',
|
||||||
|
params: {'type': 'privacy'},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
OutlinedButton.icon(
|
OutlinedButton.icon(
|
||||||
onPressed: () => _deleteAccount(context, ref),
|
onPressed: () => _deleteAccount(context, ref),
|
||||||
icon: const Icon(LucideIcons.trash, size: 19),
|
icon: const Icon(LucideIcons.trash, size: 19),
|
||||||
@@ -106,12 +96,13 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
style: OutlinedButton.styleFrom(
|
style: OutlinedButton.styleFrom(
|
||||||
foregroundColor: AppColors.error,
|
foregroundColor: AppColors.error,
|
||||||
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
||||||
|
backgroundColor: Colors.white,
|
||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)),
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 12),
|
||||||
ElevatedButton.icon(
|
ElevatedButton.icon(
|
||||||
onPressed: () => _logout(context, ref),
|
onPressed: () => _logout(context, ref),
|
||||||
icon: const Icon(LucideIcons.logOut, size: 19),
|
icon: const Icon(LucideIcons.logOut, size: 19),
|
||||||
@@ -122,7 +113,7 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
minimumSize: const Size.fromHeight(52),
|
minimumSize: const Size.fromHeight(52),
|
||||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -181,116 +172,6 @@ class SettingsPage extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SettingsHeader extends StatelessWidget {
|
|
||||||
const _SettingsHeader();
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Container(
|
|
||||||
padding: const EdgeInsets.all(18),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFFEEF2FF), Color(0xFFF7ECFF), Color(0xFFEFFBF9)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(28),
|
|
||||||
border: Border.all(color: Colors.white, width: 1.5),
|
|
||||||
),
|
|
||||||
child: Row(
|
|
||||||
children: [
|
|
||||||
Container(
|
|
||||||
width: 52,
|
|
||||||
height: 52,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: const LinearGradient(
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
colors: [Color(0xFF38BDF8), Color(0xFFC084FC)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(20),
|
|
||||||
),
|
|
||||||
child: const Icon(
|
|
||||||
LucideIcons.settings,
|
|
||||||
color: Colors.white,
|
|
||||||
size: 25,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 14),
|
|
||||||
const Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
'偏好设置',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 20,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
SizedBox(height: 5),
|
|
||||||
Text(
|
|
||||||
'管理设备、通知和账号安全',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 13,
|
|
||||||
fontWeight: FontWeight.w600,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsGroup extends StatelessWidget {
|
|
||||||
final String title;
|
|
||||||
final List<Widget> children;
|
|
||||||
const _SettingsGroup({required this.title, required this.children});
|
|
||||||
|
|
||||||
@override
|
|
||||||
Widget build(BuildContext context) {
|
|
||||||
return Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Padding(
|
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
|
||||||
child: Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 14,
|
|
||||||
fontWeight: FontWeight.w900,
|
|
||||||
color: AppColors.textSecondary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.white.withValues(alpha: 0.88),
|
|
||||||
borderRadius: BorderRadius.circular(26),
|
|
||||||
border: Border.all(color: Colors.white, width: 1.4),
|
|
||||||
boxShadow: AppColors.cardShadowLight,
|
|
||||||
),
|
|
||||||
child: Column(
|
|
||||||
children: [
|
|
||||||
for (var i = 0; i < children.length; i++) ...[
|
|
||||||
children[i],
|
|
||||||
if (i != children.length - 1)
|
|
||||||
const Divider(height: 1, color: Color(0xFFF1F3F8)),
|
|
||||||
],
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _SettingsTile extends StatelessWidget {
|
class _SettingsTile extends StatelessWidget {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final String title;
|
final String title;
|
||||||
@@ -307,59 +188,56 @@ class _SettingsTile extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return InkWell(
|
return Material(
|
||||||
onTap: onTap,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(18),
|
borderRadius: BorderRadius.circular(14),
|
||||||
child: Padding(
|
child: InkWell(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
|
onTap: onTap,
|
||||||
child: Row(
|
borderRadius: BorderRadius.circular(14),
|
||||||
children: [
|
child: Container(
|
||||||
Container(
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
width: 42,
|
decoration: BoxDecoration(
|
||||||
height: 42,
|
color: Colors.white,
|
||||||
decoration: BoxDecoration(
|
borderRadius: BorderRadius.circular(14),
|
||||||
gradient: LinearGradient(
|
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||||
begin: Alignment.topLeft,
|
),
|
||||||
end: Alignment.bottomRight,
|
child: Row(
|
||||||
colors: colors,
|
children: [
|
||||||
),
|
Icon(icon, color: const Color(0xFF64748B), size: 22),
|
||||||
borderRadius: BorderRadius.circular(16),
|
const SizedBox(width: 14),
|
||||||
),
|
Expanded(
|
||||||
child: Icon(icon, color: Colors.white, size: 21),
|
child: Column(
|
||||||
),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
const SizedBox(width: 13),
|
children: [
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Text(
|
|
||||||
title,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w800,
|
|
||||||
color: AppColors.textPrimary,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (subtitle != null) ...[
|
|
||||||
const SizedBox(height: 3),
|
|
||||||
Text(
|
Text(
|
||||||
subtitle!,
|
title,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 12,
|
fontSize: 16,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: AppColors.textHint,
|
color: AppColors.textPrimary,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
if (subtitle != null) ...[
|
||||||
|
const SizedBox(height: 3),
|
||||||
|
Text(
|
||||||
|
subtitle!,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: AppColors.textHint,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
],
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
const Icon(
|
||||||
const Icon(
|
Icons.arrow_forward_ios_rounded,
|
||||||
Icons.arrow_forward_ios_rounded,
|
size: 15,
|
||||||
size: 16,
|
color: AppColors.textHint,
|
||||||
color: AppColors.textHint,
|
),
|
||||||
),
|
],
|
||||||
],
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -68,28 +68,25 @@ class _AccountHeader extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
InkWell(
|
InkWell(
|
||||||
onTap: () => pushRoute(ref, 'profile'),
|
onTap: () => pushRoute(ref, 'profile'),
|
||||||
borderRadius: BorderRadius.circular(24),
|
borderRadius: BorderRadius.circular(33),
|
||||||
child: Container(
|
child: Container(
|
||||||
width: 66,
|
width: 66,
|
||||||
height: 66,
|
height: 66,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
gradient: const LinearGradient(
|
color: Colors.white,
|
||||||
begin: Alignment.topLeft,
|
shape: BoxShape.circle,
|
||||||
end: Alignment.bottomRight,
|
border: Border.all(color: const Color(0xFFE9EEF5)),
|
||||||
colors: [Color(0xFFA5F3FC), Color(0xFFD8B4FE)],
|
|
||||||
),
|
|
||||||
borderRadius: BorderRadius.circular(24),
|
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.12),
|
color: const Color(0xFF101828).withValues(alpha: 0.06),
|
||||||
blurRadius: 22,
|
blurRadius: 14,
|
||||||
offset: const Offset(0, 12),
|
offset: const Offset(0, 6),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
child: const Icon(
|
child: const Icon(
|
||||||
Icons.person_rounded,
|
Icons.person_rounded,
|
||||||
color: Colors.white,
|
color: Color(0xFF94A3B8),
|
||||||
size: 34,
|
size: 34,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -357,9 +354,9 @@ class _NavigationSection extends StatelessWidget {
|
|||||||
return _Panel(
|
return _Panel(
|
||||||
title: '功能入口',
|
title: '功能入口',
|
||||||
backgroundGradient: const LinearGradient(
|
backgroundGradient: const LinearGradient(
|
||||||
begin: Alignment.bottomCenter,
|
begin: Alignment.topCenter,
|
||||||
end: Alignment.topCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [Color(0xFFA8EDEA), Color(0xFFFED6E3)],
|
colors: [Color(0xFFEAF4FB), Color(0xFFF4F9FD)],
|
||||||
),
|
),
|
||||||
child: GridView.builder(
|
child: GridView.builder(
|
||||||
itemCount: items.length,
|
itemCount: items.length,
|
||||||
|
|||||||
Reference in New Issue
Block a user