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 DoctorReply { 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 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")
|
||||
.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")
|
||||
.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.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) =>
|
||||
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<ExerciseReminderService>();
|
||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||
builder.Services.AddHostedService<HealthRecordReminderService>();
|
||||
builder.Services.AddHostedService<MedicationReminderWorker>();
|
||||
builder.Services.AddHostedService<CleanupService>();
|
||||
builder.Services.AddHostedService<ReportAnalysisWorker>();
|
||||
|
||||
@@ -4,11 +4,9 @@ import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
|
||||
/// 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(
|
||||
'API_BASE_URL',
|
||||
defaultValue: 'http://10.4.170.202:5000',
|
||||
defaultValue: 'http://10.4.191.129:5000',
|
||||
);
|
||||
|
||||
class ApiException implements Exception {
|
||||
|
||||
@@ -361,10 +361,8 @@ class AppBackground extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final content = safeArea ? SafeArea(child: child) : child;
|
||||
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: content,
|
||||
);
|
||||
// 全局浅灰白背景,已弃用紫色渐变。
|
||||
return ColoredBox(color: const Color(0xFFF6F8FC), child: content);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -387,16 +385,15 @@ class GradientScaffold extends StatelessWidget {
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => AppBackground(
|
||||
child: Scaffold(
|
||||
backgroundColor: Colors.transparent,
|
||||
Widget build(BuildContext context) => Scaffold(
|
||||
// 内部页面统一使用浅灰白底,对话流首页因直接使用 AppBackground 仍保留紫色渐变。
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: appBar,
|
||||
body: body,
|
||||
floatingActionButton: floatingActionButton,
|
||||
drawer: drawer,
|
||||
bottomNavigationBar: bottomNavigationBar,
|
||||
extendBody: extendBody,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -563,6 +560,12 @@ class AppTheme {
|
||||
),
|
||||
),
|
||||
|
||||
// TextButton 全局默认黑色文字,避免出现浅紫色按钮。
|
||||
// 需要红色(如删除)时仍可在使用处单独 styleFrom(foregroundColor: AppColors.error) 覆盖。
|
||||
textButtonTheme: TextButtonThemeData(
|
||||
style: TextButton.styleFrom(foregroundColor: text),
|
||||
),
|
||||
|
||||
dialogTheme: DialogThemeData(
|
||||
backgroundColor: surface,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
|
||||
@@ -360,9 +360,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
final state = ref.watch(dietProvider);
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
|
||||
return Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: SingleChildScrollView(
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -406,7 +404,6 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1484,39 +1484,44 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
boText == null &&
|
||||
wtText == null;
|
||||
|
||||
// 异常检测
|
||||
final bpAbnormal =
|
||||
bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140;
|
||||
final hasAbnormal = bpAbnormal;
|
||||
// ── 多指标异常检测 ──
|
||||
final abnormals = <String>[];
|
||||
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 偏高');
|
||||
}
|
||||
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. 健康指标 ──
|
||||
// 没记录则整行不显示(避免每天唠叨提醒)
|
||||
const healthIconColor = Color(0xFF3B82F6);
|
||||
const healthIconBg = Color(0xFFDBEAFE);
|
||||
if (allNull) {
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
Icons.monitor_heart_outlined,
|
||||
'健康指标',
|
||||
trailing: '今日暂无记录,点击录入',
|
||||
status: 'pending',
|
||||
iconColor: healthIconColor,
|
||||
iconBg: healthIconBg,
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
),
|
||||
);
|
||||
// 不显示
|
||||
} else if (hasAbnormal) {
|
||||
// 有异常时显示异常指标
|
||||
final abnormalParts = <String>[];
|
||||
if (bpAbnormal) {
|
||||
abnormalParts.add('血压 ${bp['systolic']}/${bp['diastolic']} 偏高');
|
||||
}
|
||||
final trailing = abnormals.length == 1
|
||||
? abnormals.first
|
||||
: '${abnormals.length} 项指标异常';
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
Icons.warning_amber_rounded,
|
||||
'健康指标',
|
||||
trailing: abnormalParts.join(' · '),
|
||||
trailing: trailing,
|
||||
status: 'warning',
|
||||
iconColor: healthIconColor,
|
||||
iconBg: healthIconBg,
|
||||
@@ -1626,72 +1631,74 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// ── 3. 用药打卡 ──
|
||||
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
||||
const medIconColor = Color(0xFFEC4899);
|
||||
const medIconBg = Color(0xFFFCE7F3);
|
||||
reminders.whenOrNull(
|
||||
data: (meds) {
|
||||
if (meds.isNotEmpty) {
|
||||
final overdueMeds = meds
|
||||
.where((m) => m['status'] == 'overdue')
|
||||
.toList();
|
||||
final upcomingMeds = meds
|
||||
.where((m) => m['status'] == 'upcoming')
|
||||
.toList();
|
||||
final takenMeds = meds.where((m) => m['status'] == 'taken').toList();
|
||||
// 未服/过期的在前面
|
||||
for (final m in [...overdueMeds, ...upcomingMeds]) {
|
||||
final name = m['name']?.toString() ?? '药品';
|
||||
final time = m['scheduledTime']?.toString() ?? '';
|
||||
final isOverdue = m['status'] == 'overdue';
|
||||
if (overdueMeds.isEmpty) return;
|
||||
|
||||
// 按过期时长排序——先把最严重的放前面
|
||||
final now = DateTime.now();
|
||||
final withDelta = overdueMeds.map((m) {
|
||||
final timeStr = m['scheduledTime']?.toString() ?? '';
|
||||
final parts = timeStr.split(':');
|
||||
DateTime? scheduled;
|
||||
if (parts.length >= 2) {
|
||||
final h = int.tryParse(parts[0]);
|
||||
final mn = int.tryParse(parts[1]);
|
||||
if (h != null && mn != null) {
|
||||
scheduled = DateTime(now.year, now.month, now.day, h, mn);
|
||||
}
|
||||
}
|
||||
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,
|
||||
name,
|
||||
trailing: '${isOverdue ? "❗过期" : "⏳待服"} $time',
|
||||
status: isOverdue ? 'overdue' : 'pending',
|
||||
title,
|
||||
trailing: trailing,
|
||||
status: status,
|
||||
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 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(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
|
||||
@@ -67,8 +67,8 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||
preferredSize: const Size.fromHeight(48),
|
||||
child: TabBar(
|
||||
controller: _tabCtrl,
|
||||
indicatorColor: AppColors.primary,
|
||||
labelColor: AppColors.primary,
|
||||
indicatorColor: AppColors.textPrimary,
|
||||
labelColor: AppColors.textPrimary,
|
||||
unselectedLabelColor: AppColors.textHint,
|
||||
labelStyle: const TextStyle(
|
||||
fontSize: 15,
|
||||
@@ -84,9 +84,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
child: const Icon(Icons.add, size: 28, color: Colors.white),
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: AppFutureView<List<Map<String, dynamic>>>(
|
||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
onRetry: _load,
|
||||
errorTitle: '用药信息加载失败',
|
||||
@@ -121,10 +119,15 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(AppTheme.sLg),
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.surfaceGradient,
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -207,7 +210,6 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage>
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,31 +144,73 @@ class _NotificationCenterPageState
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final items = _history?.items ?? const <InAppNotification>[];
|
||||
final unread = _history?.unreadCount ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
body: AppBackground(
|
||||
safeArea: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_Header(
|
||||
unreadCount: _history?.unreadCount ?? 0,
|
||||
markingAll: _markingAll,
|
||||
onBack: () => popRoute(ref),
|
||||
onMarkAllRead: _markAllRead,
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0.5,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
Expanded(
|
||||
child: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: const Color(0xFF438CE1),
|
||||
child: _buildBody(items),
|
||||
title: const Text(
|
||||
'通知中心',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
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) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
@@ -203,15 +245,19 @@ class _NotificationCenterPageState
|
||||
|
||||
return ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 32),
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
|
||||
children: [
|
||||
if (unread > 0) _UnreadHint(unreadCount: unread),
|
||||
const SizedBox(height: 4),
|
||||
if (today.isNotEmpty) ...[
|
||||
const _SectionTitle('今天'),
|
||||
const SizedBox(height: 4),
|
||||
...today.map(_buildDismissible),
|
||||
],
|
||||
if (earlier.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
if (today.isNotEmpty) const SizedBox(height: 12),
|
||||
const _SectionTitle('更早'),
|
||||
const SizedBox(height: 4),
|
||||
...earlier.map(_buildDismissible),
|
||||
],
|
||||
],
|
||||
@@ -219,18 +265,39 @@ class _NotificationCenterPageState
|
||||
}
|
||||
|
||||
Widget _buildDismissible(InAppNotification item) => Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 24),
|
||||
padding: const EdgeInsets.only(right: 26),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
gradient: LinearGradient(
|
||||
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),
|
||||
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 bool markingAll;
|
||||
final VoidCallback onBack;
|
||||
final VoidCallback onMarkAllRead;
|
||||
|
||||
const _Header({
|
||||
required this.unreadCount,
|
||||
required this.markingAll,
|
||||
required this.onBack,
|
||||
required this.onMarkAllRead,
|
||||
});
|
||||
const _UnreadHint({required this.unreadCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 8, 12, 10),
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
tooltip: '返回',
|
||||
onPressed: onBack,
|
||||
icon: const Icon(LucideIcons.chevronLeft),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Text(
|
||||
'通知中心',
|
||||
style: TextStyle(
|
||||
fontSize: 21,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFEF4444),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
|
||||
'你有 $unreadCount 条未读消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (unreadCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: markingAll ? null : onMarkAllRead,
|
||||
icon: markingAll
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(LucideIcons.checkCheck, size: 17),
|
||||
label: const Text('全部已读'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
@@ -308,13 +345,14 @@ class _SectionTitle extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 10),
|
||||
padding: const EdgeInsets.fromLTRB(4, 12, 4, 6),
|
||||
child: Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
color: AppColors.textHint,
|
||||
letterSpacing: 0.3,
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -330,89 +368,85 @@ class _NotificationCard extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final visual = _NotificationVisual.of(item);
|
||||
return Material(
|
||||
color: item.isRead
|
||||
? Colors.white.withValues(alpha: 0.72)
|
||||
: Colors.white.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
elevation: item.isRead ? 0 : 1,
|
||||
shadowColor: visual.color.withValues(alpha: 0.12),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: item.isRead
|
||||
? const Color(0xFFE9EEF5)
|
||||
: visual.color.withValues(alpha: 0.18),
|
||||
? const Color(0xFFEEF1F6)
|
||||
: 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(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
// 图标
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
width: 36,
|
||||
height: 36,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [visual.lightColor, Colors.white],
|
||||
color: visual.lightColor,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Icon(visual.icon, size: 18, color: visual.color),
|
||||
),
|
||||
child: Icon(visual.icon, size: 22, color: visual.color),
|
||||
if (!item.isRead)
|
||||
Positioned(
|
||||
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(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 14,
|
||||
fontWeight: item.isRead
|
||||
? FontWeight.w600
|
||||
: FontWeight.w800,
|
||||
: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.3,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (!item.isRead)
|
||||
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: [
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
@@ -420,31 +454,30 @@ class _NotificationCard extends StatelessWidget {
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
if (item.actionType != null)
|
||||
Row(
|
||||
children: [
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'查看详情',
|
||||
style: TextStyle(
|
||||
item.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: visual.color,
|
||||
height: 1.45,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 2),
|
||||
Icon(
|
||||
LucideIcons.chevronRight,
|
||||
size: 14,
|
||||
color: visual.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
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 Color color;
|
||||
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) {
|
||||
switch (item.actionType) {
|
||||
case 'exercise':
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.activity,
|
||||
Color(0xFF2C9A70),
|
||||
Color(0xFFDDF7EC),
|
||||
Color(0xFF10B981),
|
||||
Color(0xFFD1FAE5),
|
||||
'运动',
|
||||
);
|
||||
case 'health':
|
||||
if (item.severity == 'critical') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
Color(0xFFE55353),
|
||||
Color(0xFFFFE7E7),
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'紧急',
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.heartPulse,
|
||||
Color(0xFFE8863A),
|
||||
Color(0xFFFFEEDC),
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFFFEF3C7),
|
||||
'健康',
|
||||
);
|
||||
case 'report':
|
||||
if (item.severity == 'error') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
Color(0xFFE55353),
|
||||
Color(0xFFFFE7E7),
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'报告',
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileCheck2,
|
||||
Color(0xFF7B68CE),
|
||||
Color(0xFFEDE9FF),
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFFEDE9FE),
|
||||
'报告',
|
||||
);
|
||||
default:
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.pill,
|
||||
Color(0xFF468AD8),
|
||||
Color(0xFFE4F0FF),
|
||||
Color(0xFF3B82F6),
|
||||
Color(0xFFDBEAFE),
|
||||
'用药',
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -540,38 +580,44 @@ class _MessageState extends StatelessWidget {
|
||||
padding: const EdgeInsets.fromLTRB(28, 120, 28, 32),
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 34),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
Colors.white.withValues(alpha: 0.95),
|
||||
const Color(0xFFF1F7FF).withValues(alpha: 0.9),
|
||||
],
|
||||
),
|
||||
color: Colors.white,
|
||||
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(
|
||||
children: [
|
||||
Container(
|
||||
width: 64,
|
||||
height: 64,
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFE5F0FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
|
||||
),
|
||||
child: Icon(icon, size: 29, color: const Color(0xFF5D91CF)),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 7),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -582,8 +628,18 @@ class _MessageState extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
if (buttonText != null) ...[
|
||||
const SizedBox(height: 20),
|
||||
FilledButton(onPressed: onPressed, child: Text(buttonText!)),
|
||||
const SizedBox(height: 22),
|
||||
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,
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: AppFutureView<List<Map<String, dynamic>>>(
|
||||
body: AppFutureView<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
onRetry: _load,
|
||||
errorTitle: '运动计划加载失败',
|
||||
@@ -770,7 +768,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
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],
|
||||
),
|
||||
child: Row(
|
||||
@@ -854,7 +852,6 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1399,7 +1396,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
@@ -1417,7 +1414,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
);
|
||||
}
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
@@ -1442,6 +1439,7 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
)
|
||||
: TextButton(
|
||||
onPressed: _save,
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.textPrimary),
|
||||
child: const Text('保存', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
),
|
||||
],
|
||||
@@ -1550,8 +1548,14 @@ class _Section extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
border: Border.all(color: const Color(0xFFD5DCE5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
|
||||
@@ -546,8 +546,14 @@ class ReportListPage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
border: Border.all(color: const Color(0xFFD5DCE5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.08),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
|
||||
@@ -3,8 +3,10 @@ import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
|
||||
// ── 通知偏好状态 ──
|
||||
// 持久化到后端 /api/notification-prefs,启动时自动拉取一次。
|
||||
|
||||
final notificationPrefsProvider =
|
||||
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>(
|
||||
@@ -12,28 +14,92 @@ final notificationPrefsProvider =
|
||||
);
|
||||
|
||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||
@override
|
||||
Map<String, bool> build() {
|
||||
// TODO: 从 SQLite 读取持久化值,此处先用默认值
|
||||
return {
|
||||
static const _defaults = {
|
||||
'pushEnabled': true,
|
||||
'medication': true,
|
||||
'healthAlert': true,
|
||||
'followUp': true,
|
||||
'aiReply': false,
|
||||
'dndEnabled': false,
|
||||
'pushEnabled': true,
|
||||
'dndStart': false, // 占位:实际用 TimeOfDay
|
||||
'dndStart': false,
|
||||
'dndEnd': false,
|
||||
// 健康录入提醒——总开关 + 5 子项
|
||||
'healthRecord': true,
|
||||
'healthRecord.bp': true,
|
||||
'healthRecord.hr': true,
|
||||
'healthRecord.glucose': true,
|
||||
'healthRecord.spo2': true,
|
||||
'healthRecord.weight': true,
|
||||
};
|
||||
|
||||
@override
|
||||
Map<String, bool> build() {
|
||||
Future.microtask(_loadFromBackend);
|
||||
return {..._defaults};
|
||||
}
|
||||
|
||||
void toggle(String key) {
|
||||
state = {...state, key: !state[key]!};
|
||||
// TODO: 持久化到 SQLite
|
||||
Future<void> _loadFromBackend() async {
|
||||
try {
|
||||
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) {
|
||||
state = {...state, 'dndStart': true}; // 简化存储
|
||||
state = {...state, 'dndStart': true};
|
||||
}
|
||||
|
||||
void setDndEnd(TimeOfDay time) {
|
||||
@@ -137,6 +203,58 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
),
|
||||
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: '免打扰时段'),
|
||||
_SwitchTile(
|
||||
|
||||
@@ -11,12 +11,16 @@ class SettingsPage extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return GradientScaffold(
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.86),
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
scrolledUnderElevation: 0.5,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
leading: IconButton(
|
||||
icon: const Icon(
|
||||
LucideIcons.chevronLeft,
|
||||
Icons.arrow_back,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
onPressed: () => popRoute(ref),
|
||||
@@ -24,8 +28,8 @@ class SettingsPage extends ConsumerWidget {
|
||||
title: const Text(
|
||||
'设置',
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -33,14 +37,9 @@ class SettingsPage extends ConsumerWidget {
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34),
|
||||
padding: const EdgeInsets.fromLTRB(16, 18, 16, 32),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const _SettingsHeader(),
|
||||
const SizedBox(height: 18),
|
||||
_SettingsGroup(
|
||||
title: '健康与设备',
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bluetooth,
|
||||
@@ -48,18 +47,14 @@ class SettingsPage extends ConsumerWidget {
|
||||
colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)],
|
||||
onTap: () => pushRoute(ref, 'devices'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.bell,
|
||||
title: '消息通知',
|
||||
colors: const [Color(0xFFA78BFA), Color(0xFF7C3AED)],
|
||||
onTap: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SettingsGroup(
|
||||
title: '使用偏好',
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.type,
|
||||
title: '字体大小',
|
||||
@@ -67,18 +62,14 @@ class SettingsPage extends ConsumerWidget {
|
||||
colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)],
|
||||
onTap: () {},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.sprayCan,
|
||||
title: '清除缓存',
|
||||
colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)],
|
||||
onTap: () {},
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_SettingsGroup(
|
||||
title: '关于',
|
||||
children: [
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.info,
|
||||
title: '关于小脉健康',
|
||||
@@ -86,6 +77,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
onTap: () =>
|
||||
pushRoute(ref, 'staticText', params: {'type': 'about'}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_SettingsTile(
|
||||
icon: LucideIcons.shield,
|
||||
title: '隐私协议',
|
||||
@@ -96,9 +88,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
params: {'type': 'privacy'},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const SizedBox(height: 24),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _deleteAccount(context, ref),
|
||||
icon: const Icon(LucideIcons.trash, size: 19),
|
||||
@@ -106,12 +96,13 @@ class SettingsPage extends ConsumerWidget {
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
||||
backgroundColor: Colors.white,
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
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(
|
||||
onPressed: () => _logout(context, ref),
|
||||
icon: const Icon(LucideIcons.logOut, size: 19),
|
||||
@@ -122,7 +113,7 @@ class SettingsPage extends ConsumerWidget {
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
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 {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
@@ -307,27 +188,23 @@ class _SettingsTile extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: colors,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 21),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Icon(icon, color: const Color(0xFF64748B), size: 22),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -336,7 +213,7 @@ class _SettingsTile extends StatelessWidget {
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
@@ -346,7 +223,7 @@ class _SettingsTile extends StatelessWidget {
|
||||
subtitle!,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
@@ -356,12 +233,13 @@ class _SettingsTile extends StatelessWidget {
|
||||
),
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 16,
|
||||
size: 15,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,28 +68,25 @@ class _AccountHeader extends StatelessWidget {
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
borderRadius: BorderRadius.circular(33),
|
||||
child: Container(
|
||||
width: 66,
|
||||
height: 66,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFA5F3FC), Color(0xFFD8B4FE)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
color: Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: const Color(0xFFE9EEF5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF7C3AED).withValues(alpha: 0.12),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 12),
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.06),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: Colors.white,
|
||||
color: Color(0xFF94A3B8),
|
||||
size: 34,
|
||||
),
|
||||
),
|
||||
@@ -357,9 +354,9 @@ class _NavigationSection extends StatelessWidget {
|
||||
return _Panel(
|
||||
title: '功能入口',
|
||||
backgroundGradient: const LinearGradient(
|
||||
begin: Alignment.bottomCenter,
|
||||
end: Alignment.topCenter,
|
||||
colors: [Color(0xFFA8EDEA), Color(0xFFFED6E3)],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFEAF4FB), Color(0xFFF4F9FD)],
|
||||
),
|
||||
child: GridView.builder(
|
||||
itemCount: items.length,
|
||||
|
||||
Reference in New Issue
Block a user