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:
MingNian
2026-06-24 22:11:21 +08:00
parent 7ff429e071
commit 53ce19e8c3
18 changed files with 2379 additions and 565 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
appBar: appBar,
body: body,
floatingActionButton: floatingActionButton,
drawer: drawer,
bottomNavigationBar: bottomNavigationBar,
extendBody: extendBody,
),
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,

View File

@@ -360,10 +360,8 @@ 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(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
return SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
child: Column(
children: [
// 图片自适应显示
@@ -406,7 +404,6 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
],
],
),
),
);
}

View File

@@ -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';
tasks.add(
_taskRow(
context,
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 overdueMeds = meds
.where((m) => m['status'] == 'overdue')
.toList();
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,
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(
margin: const EdgeInsets.only(bottom: 12),

View File

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

View File

@@ -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,
),
Expanded(
child: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF438CE1),
child: _buildBody(items),
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),
),
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),
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,
),
),
const SizedBox(height: 2),
Text(
unreadCount == 0 ? '所有消息均已读' : '$unreadCount 条未读消息',
style: const TextStyle(
fontSize: 12,
color: AppColors.textSecondary,
),
),
],
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Row(
children: [
Container(
width: 6,
height: 6,
decoration: const BoxDecoration(
color: Color(0xFFEF4444),
shape: BoxShape.circle,
),
),
),
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('全部已读'),
const SizedBox(width: 8),
Text(
'你有 $unreadCount 条未读消息',
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
],
),
);
],
),
);
}
}
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: [
Container(
width: 46,
height: 46,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [visual.lightColor, Colors.white],
// 图标
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: visual.lightColor,
borderRadius: BorderRadius.circular(10),
),
child: Icon(visual.icon, size: 18, color: visual.color),
),
borderRadius: BorderRadius.circular(14),
),
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: [
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 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)],
),
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(
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!),
),
],
],
),

View File

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

View File

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

View File

@@ -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>> {
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
Map<String, bool> build() {
// TODO: 从 SQLite 读取持久化值,此处先用默认值
return {
'medication': true,
'healthAlert': true,
'followUp': true,
'aiReply': false,
'dndEnabled': false,
'pushEnabled': true,
'dndStart': false, // 占位:实际用 TimeOfDay
'dndEnd': false,
};
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(

View File

@@ -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,72 +37,58 @@ 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,
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'),
),
],
_SettingsTile(
icon: LucideIcons.bluetooth,
title: '蓝牙设备',
colors: const [Color(0xFF38BDF8), Color(0xFF2563EB)],
onTap: () => pushRoute(ref, 'devices'),
),
const SizedBox(height: 16),
_SettingsGroup(
title: '使用偏好',
children: [
_SettingsTile(
icon: LucideIcons.type,
title: '字体大小',
subtitle: '老年友好',
colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)],
onTap: () {},
),
_SettingsTile(
icon: LucideIcons.sprayCan,
title: '清除缓存',
colors: const [Color(0xFF2DD4BF), Color(0xFF0F766E)],
onTap: () {},
),
],
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: [
_SettingsTile(
icon: LucideIcons.info,
title: '关于小脉健康',
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: 8),
_SettingsTile(
icon: LucideIcons.type,
title: '字体大小',
subtitle: '老年友好',
colors: const [Color(0xFFFBBF24), Color(0xFFEA580C)],
onTap: () {},
),
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(
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,59 +188,56 @@ class _SettingsTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(18),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
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),
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),
return Material(
color: Colors.white,
borderRadius: BorderRadius.circular(14),
child: InkWell(
onTap: onTap,
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: [
Icon(icon, color: const Color(0xFF64748B), size: 22),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
subtitle!,
title,
style: const TextStyle(
fontSize: 12,
fontSize: 16,
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(
Icons.arrow_forward_ios_rounded,
size: 16,
color: AppColors.textHint,
),
],
const Icon(
Icons.arrow_forward_ios_rounded,
size: 15,
color: AppColors.textHint,
),
],
),
),
),
);

View File

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