feat: 通知推送/免打扰偏好 + 饮食记录侧滑删除修复 + 聊天交互优化

- 通知: 新增推送开关/免打扰时段偏好持久化 + EF 迁移; 通知管线支持免打扰过滤
- 饮食: 侧滑删除 confirmDismiss 按方向放行(修复删不掉); 新增 diet_nutrition_widgets; 饮食录入页重构
- 聊天: 智能体胶囊点击锁防误触; 流式状态 select 优化; 卡片入场动画
- 主页: 消息列表提取 _HomeMessages + 侧滑手势打开抽屉
- 其他: api_client IP 适配; 通知/用药/饮食端点微调; 测试更新
This commit is contained in:
MingNian
2026-07-13 10:39:34 +08:00
parent 335a3e6440
commit e654c1e0cc
19 changed files with 2140 additions and 343 deletions

View File

@@ -89,6 +89,10 @@ public sealed class NotificationPreference
public bool FollowUpReminder { get; set; } = true; public bool FollowUpReminder { get; set; } = true;
public bool DoctorReply { get; set; } = true; public bool DoctorReply { get; set; } = true;
public bool AbnormalAlert { get; set; } = true; public bool AbnormalAlert { get; set; } = true;
public bool PushEnabled { get; set; } = true;
public bool DndEnabled { get; set; }
public int DndStartMinutes { get; set; } = 22 * 60;
public int DndEndMinutes { get; set; } = 8 * 60;
// 健康录入提醒——总开关 + 5 子项 // 健康录入提醒——总开关 + 5 子项
public bool HealthRecordReminder { get; set; } = true; public bool HealthRecordReminder { get; set; } = true;
public bool HealthRecordReminderBloodPressure { get; set; } = true; public bool HealthRecordReminderBloodPressure { get; set; } = true;

View File

@@ -0,0 +1,62 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Health.Infrastructure.Data.Migrations
{
/// <inheritdoc />
public partial class AddPushAndDndPreferences : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "DndEnabled",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<int>(
name: "DndEndMinutes",
table: "NotificationPreferences",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "DndStartMinutes",
table: "NotificationPreferences",
type: "integer",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<bool>(
name: "PushEnabled",
table: "NotificationPreferences",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "DndEnabled",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "DndEndMinutes",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "DndStartMinutes",
table: "NotificationPreferences");
migrationBuilder.DropColumn(
name: "PushEnabled",
table: "NotificationPreferences");
}
}
}

View File

@@ -673,6 +673,15 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("AbnormalAlert") b.Property<bool>("AbnormalAlert")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("DndEnabled")
.HasColumnType("boolean");
b.Property<int>("DndEndMinutes")
.HasColumnType("integer");
b.Property<int>("DndStartMinutes")
.HasColumnType("integer");
b.Property<bool>("DoctorReply") b.Property<bool>("DoctorReply")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -700,6 +709,9 @@ namespace Health.Infrastructure.Data.Migrations
b.Property<bool>("MedicationReminder") b.Property<bool>("MedicationReminder")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("PushEnabled")
.HasColumnType("boolean");
b.Property<DateTime>("UpdatedAt") b.Property<DateTime>("UpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");

View File

@@ -94,7 +94,11 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload) var message = JsonSerializer.Deserialize<NotificationMessage>(candidate.Payload)
?? throw new InvalidOperationException("通知消息内容为空"); ?? throw new InvalidOperationException("通知消息内容为空");
if (!await _db.UserNotifications.AsNoTracking() var preference = await _db.NotificationPreferences.AsNoTracking()
.FirstOrDefaultAsync(x => x.UserId == candidate.UserId, ct);
var deliver = preference == null || ShouldDeliver(preference, message.Type, DateTime.UtcNow.AddHours(8));
if (deliver && !await _db.UserNotifications.AsNoTracking()
.AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct)) .AnyAsync(x => x.SourceId == candidate.SourceTaskId, ct))
{ {
await _db.UserNotifications.AddAsync(new UserNotification await _db.UserNotifications.AddAsync(new UserNotification
@@ -136,4 +140,25 @@ public sealed class EfNotificationOutboxProcessor(AppDbContext db) : INotificati
return true; return true;
} }
private static bool ShouldDeliver(NotificationPreference preference, string type, DateTime nowCst)
{
if (!preference.PushEnabled) return false;
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
var inDnd = preference.DndEnabled && (preference.DndStartMinutes <= preference.DndEndMinutes
? minuteOfDay >= preference.DndStartMinutes && minuteOfDay < preference.DndEndMinutes
: minuteOfDay >= preference.DndStartMinutes || minuteOfDay < preference.DndEndMinutes);
if (inDnd) return false;
return type switch
{
"medication_reminder" => preference.MedicationReminder,
"follow_up_reminder" => preference.FollowUpReminder,
"doctor_reply" => preference.DoctorReply,
"abnormal_alert" => preference.AbnormalAlert,
"health_record_reminder" => preference.HealthRecordReminder,
_ => true,
};
}
} }

View File

@@ -54,11 +54,16 @@ public sealed class HealthRecordReminderService(
// 取所有启用了健康录入提醒的用户偏好 // 取所有启用了健康录入提醒的用户偏好
var prefs = await db.NotificationPreferences var prefs = await db.NotificationPreferences
.Where(p => p.HealthRecordReminder) .Where(p => p.PushEnabled && p.HealthRecordReminder)
.ToListAsync(ct); .ToListAsync(ct);
foreach (var pref in prefs) foreach (var pref in prefs)
{ {
var minuteOfDay = nowCst.Hour * 60 + nowCst.Minute;
var inDnd = pref.DndEnabled && (pref.DndStartMinutes <= pref.DndEndMinutes
? minuteOfDay >= pref.DndStartMinutes && minuteOfDay < pref.DndEndMinutes
: minuteOfDay >= pref.DndStartMinutes || minuteOfDay < pref.DndEndMinutes);
if (inDnd) continue;
// 该用户当天已录入的指标 // 该用户当天已录入的指标
var recordedTypes = await db.HealthRecords var recordedTypes = await db.HealthRecords
.Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd) .Where(r => r.UserId == pref.UserId && r.RecordedAt >= todayStart && r.RecordedAt < todayEnd)

View File

@@ -26,8 +26,10 @@ public static class DietEndpoints
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
await diets.DeleteAsync(userId, id, ct); var deleted = await diets.DeleteAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return deleted
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "饮食记录不存在" });
}); });
group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) => group.MapPut("/{id:guid}", async (Guid id, HttpContext http, IDietService diets, CancellationToken ct) =>

View File

@@ -36,8 +36,10 @@ public static class MedicationEndpoints
group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>
{ {
var userId = GetUserId(http); var userId = GetUserId(http);
await medications.DeleteAsync(userId, id, ct); var deleted = await medications.DeleteAsync(userId, id, ct);
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null }); return deleted
? Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null })
: Results.Ok(new { code = 40004, data = (object?)null, message = "用药记录不存在" });
}); });
group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) => group.MapPost("/{id:guid}/confirm", async (Guid id, HttpContext http, IMedicationService medications, CancellationToken ct) =>

View File

@@ -90,6 +90,10 @@ public static class NotificationEndpoints
if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value; if (body.FollowUpReminder.HasValue) pref.FollowUpReminder = body.FollowUpReminder.Value;
if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value; if (body.DoctorReply.HasValue) pref.DoctorReply = body.DoctorReply.Value;
if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value; if (body.AbnormalAlert.HasValue) pref.AbnormalAlert = body.AbnormalAlert.Value;
if (body.PushEnabled.HasValue) pref.PushEnabled = body.PushEnabled.Value;
if (body.DndEnabled.HasValue) pref.DndEnabled = body.DndEnabled.Value;
if (body.DndStartMinutes is >= 0 and < 1440) pref.DndStartMinutes = body.DndStartMinutes.Value;
if (body.DndEndMinutes is >= 0 and < 1440) pref.DndEndMinutes = body.DndEndMinutes.Value;
if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value; if (body.HealthRecordReminder.HasValue) pref.HealthRecordReminder = body.HealthRecordReminder.Value;
if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value; if (body.HealthRecordReminderBloodPressure.HasValue) pref.HealthRecordReminderBloodPressure = body.HealthRecordReminderBloodPressure.Value;
if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value; if (body.HealthRecordReminderHeartRate.HasValue) pref.HealthRecordReminderHeartRate = body.HealthRecordReminderHeartRate.Value;
@@ -153,6 +157,10 @@ public static class NotificationEndpoints
followUpReminder = pref.FollowUpReminder, followUpReminder = pref.FollowUpReminder,
doctorReply = pref.DoctorReply, doctorReply = pref.DoctorReply,
abnormalAlert = pref.AbnormalAlert, abnormalAlert = pref.AbnormalAlert,
pushEnabled = pref.PushEnabled,
dndEnabled = pref.DndEnabled,
dndStartMinutes = pref.DndStartMinutes,
dndEndMinutes = pref.DndEndMinutes,
healthRecordReminder = pref.HealthRecordReminder, healthRecordReminder = pref.HealthRecordReminder,
healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure, healthRecordReminderBloodPressure = pref.HealthRecordReminderBloodPressure,
healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate, healthRecordReminderHeartRate = pref.HealthRecordReminderHeartRate,
@@ -167,6 +175,10 @@ public sealed record NotificationPrefsUpdateDto(
bool? FollowUpReminder, bool? FollowUpReminder,
bool? DoctorReply, bool? DoctorReply,
bool? AbnormalAlert, bool? AbnormalAlert,
bool? PushEnabled,
bool? DndEnabled,
int? DndStartMinutes,
int? DndEndMinutes,
bool? HealthRecordReminder, bool? HealthRecordReminder,
bool? HealthRecordReminderBloodPressure, bool? HealthRecordReminderBloodPressure,
bool? HealthRecordReminderHeartRate, bool? HealthRecordReminderHeartRate,

View File

@@ -6,7 +6,7 @@ import 'local_database.dart';
/// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。
const String baseUrl = String.fromEnvironment( const String baseUrl = String.fromEnvironment(
'API_BASE_URL', 'API_BASE_URL',
defaultValue: 'http://10.4.251.10:5000', defaultValue: 'http://192.168.1.34:5000',
); );
class ApiException implements Exception { class ApiException implements Exception {

View File

@@ -9,6 +9,7 @@ import '../../core/app_design_tokens.dart';
import '../../core/app_theme.dart'; import '../../core/app_theme.dart';
import '../../providers/auth_provider.dart'; import '../../providers/auth_provider.dart';
import '../../widgets/app_toast.dart'; import '../../widgets/app_toast.dart';
import 'diet_nutrition_widgets.dart';
import 'diet_record_logic.dart'; import 'diet_record_logic.dart';
final dietProvider = NotifierProvider<DietNotifier, DietState>( final dietProvider = NotifierProvider<DietNotifier, DietState>(
@@ -27,7 +28,7 @@ class DietState {
DietState({ DietState({
this.imagePath, this.imagePath,
this.foods = const [], this.foods = const [],
this.mealType = 'lunch', this.mealType = '',
this.isAnalyzing = false, this.isAnalyzing = false,
this.healthScore, this.healthScore,
this.errorMessage, this.errorMessage,
@@ -271,6 +272,9 @@ class DietNotifier extends Notifier<DietState> {
} }
Future<void> saveRecord() async { Future<void> saveRecord() async {
if (state.mealType.isEmpty) {
throw const DietFoodValidationException('请选择餐次');
}
final selectedFoods = state.foods.where((f) => f.selected).toList(); final selectedFoods = state.foods.where((f) => f.selected).toList();
if (selectedFoods.isEmpty) { if (selectedFoods.isEmpty) {
throw const DietFoodValidationException('请至少选择一种食物'); throw const DietFoodValidationException('请至少选择一种食物');
@@ -296,7 +300,7 @@ class DietNotifier extends Notifier<DietState> {
await api.post( await api.post(
'/api/diet-records', '/api/diet-records',
data: { data: {
'mealType': mealMap[state.mealType] ?? 'Lunch', 'mealType': mealMap[state.mealType]!,
'totalCalories': totalCal, 'totalCalories': totalCal,
'healthScore': state.healthScore ?? 3, 'healthScore': state.healthScore ?? 3,
'recordedAt': DateTime.now().toIso8601String().substring(0, 10), 'recordedAt': DateTime.now().toIso8601String().substring(0, 10),
@@ -318,9 +322,9 @@ class DietNotifier extends Notifier<DietState> {
} }
// ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── // ─────────── 饮食主题色(暖橙系,不再用紫色)───────────
const _dietAccent = AppColors.diet; const _dietAccent = DietPalette.primary;
const _dietKcalText = Color(0xFF9A3412); const _dietKcalText = DietPalette.calorie;
const _dietGradient = AppColors.dietGradient; const _dietGradient = DietPalette.gradient;
class DietCapturePage extends ConsumerStatefulWidget { class DietCapturePage extends ConsumerStatefulWidget {
const DietCapturePage({super.key}); const DietCapturePage({super.key});
@@ -428,47 +432,47 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
children: [ children: [
Row( Row(
children: [ children: [
Container( const Text('本餐估算', style: AppTextStyles.sectionTitle),
width: 30,
height: 30,
decoration: BoxDecoration(
gradient: _dietGradient,
borderRadius: AppRadius.smBorder,
),
child: const Icon(
Icons.local_fire_department_rounded,
size: 18,
color: Colors.white,
),
),
const SizedBox(width: 10),
const Text(
'本餐估算',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w900,
color: AppColors.textPrimary,
),
),
const Spacer(), const Spacer(),
_MealTypeMenu( _MealTypeSelector(
value: state.mealType, value: state.mealType,
onChanged: (type) => onChanged: (type) =>
ref.read(dietProvider.notifier).setMealType(type), ref.read(dietProvider.notifier).setMealType(type),
), ),
], ],
), ),
const SizedBox(height: 13), const SizedBox(height: 20),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
_MacroStat(label: '总热量', value: '$totalCal', unit: '千卡'), DietCalorieRing(calories: totalCal),
const SizedBox(width: 8), const SizedBox(width: 20),
_MacroStat(label: '蛋白质', value: '$protein', unit: 'g'), Expanded(
const SizedBox(width: 8), child: Column(
_MacroStat(label: '碳水', value: '$carbs', unit: 'g'), children: [
const SizedBox(width: 8), DietMacroBar(
_MacroStat(label: '脂肪', value: '$fat', unit: 'g'), label: '蛋白质',
value: protein,
color: DietPalette.protein,
reference: 50,
),
const SizedBox(height: 14),
DietMacroBar(
label: '碳水',
value: carbs,
color: DietPalette.carbs,
reference: 100,
),
const SizedBox(height: 14),
DietMacroBar(
label: '脂肪',
value: fat,
color: DietPalette.fat,
reference: 35,
),
],
),
),
], ],
), ),
], ],
@@ -603,7 +607,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
return Column( return Column(
children: [ children: [
Padding( Padding(
padding: const EdgeInsets.fromLTRB(14, 11, 12, 11), padding: const EdgeInsets.fromLTRB(14, 14, 12, 14),
child: Row( child: Row(
children: [ children: [
GestureDetector( GestureDetector(
@@ -615,12 +619,12 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
alignment: Alignment.center, alignment: Alignment.center,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: food.selected ? _dietGradient : null, gradient: food.selected ? _dietGradient : null,
color: food.selected ? null : AppColors.dietLight, color: food.selected ? null : DietPalette.primarySoft,
borderRadius: AppRadius.smBorder, borderRadius: AppRadius.smBorder,
border: Border.all( border: Border.all(
color: food.selected color: food.selected
? Colors.transparent ? Colors.transparent
: AppColors.dietBorder, : const Color(0xFFC9D0FF),
), ),
), ),
child: food.selected child: food.selected
@@ -638,8 +642,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
.updateFoodName(food.id, v), .updateFoodName(food.id, v),
fieldKey: '${food.id}_name', fieldKey: '${food.id}_name',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
@@ -655,7 +659,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
fieldKey: '${food.id}_portion', fieldKey: '${food.id}_portion',
hint: '份量', hint: '份量',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -663,7 +667,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
SizedBox( SizedBox(
width: 88, width: 96,
child: Row( child: Row(
children: [ children: [
Expanded( Expanded(
@@ -677,8 +681,8 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
align: TextAlign.right, align: TextAlign.right,
keyboardType: TextInputType.number, keyboardType: TextInputType.number,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: _dietKcalText, color: _dietKcalText,
), ),
), ),
@@ -686,7 +690,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
const Text( const Text(
' 千卡', ' 千卡',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 13,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textHint, color: AppColors.textHint,
), ),
@@ -722,11 +726,18 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
textAlign: align, textAlign: align,
style: style ?? const TextStyle(fontSize: 15), style: style ?? const TextStyle(fontSize: 15),
decoration: InputDecoration( decoration: InputDecoration(
filled: false,
fillColor: Colors.transparent,
isDense: true, isDense: true,
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
border: InputBorder.none, border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
hintText: hint, hintText: hint,
hintStyle: const TextStyle(fontSize: 13, color: AppColors.textHint), hintStyle: const TextStyle(fontSize: 16, color: AppColors.textHint),
), ),
); );
} }
@@ -853,58 +864,39 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
} }
} }
class _MealTypeMenu extends StatelessWidget { class _MealTypeSelector extends StatelessWidget {
final String value; final String value;
final ValueChanged<String> onChanged; final ValueChanged<String> onChanged;
const _MealTypeMenu({required this.value, required this.onChanged}); const _MealTypeSelector({required this.value, required this.onChanged});
static const _options = [ static const _options = [
(label: '早餐', value: 'breakfast'), (label: '早餐', value: 'breakfast', icon: Icons.wb_sunny_outlined),
(label: '午餐', value: 'lunch'), (label: '午餐', value: 'lunch', icon: Icons.light_mode_outlined),
(label: '晚餐', value: 'dinner'), (label: '晚餐', value: 'dinner', icon: Icons.nights_stay_outlined),
(label: '加餐', value: 'snack'), (label: '加餐', value: 'snack', icon: Icons.cookie_outlined),
]; ];
String get _label { String get _label {
for (final option in _options) { for (final option in _options) {
if (option.value == value) return option.label; if (option.value == value) return option.label;
} }
return ''; return '';
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return PopupMenuButton<String>( final selected = value.isNotEmpty;
initialValue: value, return GestureDetector(
onSelected: onChanged, onTap: () => _showOptions(context),
color: Colors.white,
elevation: 8,
shape: RoundedRectangleBorder(borderRadius: AppRadius.mdBorder),
itemBuilder: (context) => [
for (final option in _options)
PopupMenuItem<String>(
value: option.value,
child: Text(
option.label,
style: TextStyle(
fontSize: 15,
fontWeight: option.value == value
? FontWeight.w900
: FontWeight.w700,
color: option.value == value
? _dietKcalText
: AppColors.textPrimary,
),
),
),
],
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 9),
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.dietLight, color: selected ? DietPalette.primarySoft : Colors.white,
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppColors.dietBorder), border: Border.all(
color: selected ? const Color(0xFFC9D0FF) : AppColors.border,
),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@@ -913,79 +905,93 @@ class _MealTypeMenu extends StatelessWidget {
_label, _label,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 14,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w700,
color: _dietKcalText, color: AppColors.textPrimary,
), ),
), ),
const SizedBox(width: 5), const SizedBox(width: 5),
const Icon( const Icon(
Icons.keyboard_arrow_down_rounded, Icons.keyboard_arrow_down_rounded,
size: 18, size: 18,
color: _dietKcalText, color: AppColors.textSecondary,
), ),
], ],
), ),
), ),
); );
} }
}
class _MacroStat extends StatelessWidget { Future<void> _showOptions(BuildContext context) async {
final String label; final selected = await showModalBottomSheet<String>(
final String value; context: context,
final String unit; backgroundColor: Colors.white,
showDragHandle: true,
const _MacroStat({ shape: const RoundedRectangleBorder(
required this.label, borderRadius: BorderRadius.vertical(top: Radius.circular(AppRadius.xl)),
required this.value, ),
required this.unit, builder: (sheetContext) => SafeArea(
}); top: false,
child: Padding(
@override padding: const EdgeInsets.fromLTRB(20, 4, 20, 20),
Widget build(BuildContext context) { child: Column(
return Expanded( mainAxisSize: MainAxisSize.min,
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ const Text('选择餐次', style: AppTextStyles.sectionTitle),
Text( const SizedBox(height: 12),
label, for (var i = 0; i < _options.length; i++) ...[
maxLines: 1, InkWell(
overflow: TextOverflow.ellipsis, borderRadius: AppRadius.mdBorder,
style: const TextStyle( onTap: () => Navigator.pop(sheetContext, _options[i].value),
fontSize: 11, child: Padding(
fontWeight: FontWeight.w700, padding: const EdgeInsets.symmetric(vertical: 13),
color: AppColors.textHint, child: Row(
), children: [
), Container(
const SizedBox(height: 3), width: 38,
FittedBox( height: 38,
fit: BoxFit.scaleDown, decoration: BoxDecoration(
alignment: Alignment.centerLeft, color: DietPalette.primarySoft,
child: Row( borderRadius: AppRadius.smBorder,
children: [ ),
Text( child: Icon(
value, _options[i].icon,
style: const TextStyle( size: 20,
fontSize: 16, color: DietPalette.primary,
height: 1, ),
fontWeight: FontWeight.w900, ),
color: _dietKcalText, const SizedBox(width: 12),
Expanded(
child: Text(
_options[i].label,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
),
if (_options[i].value == value)
const Icon(
Icons.check_rounded,
size: 21,
color: DietPalette.primary,
),
],
),
), ),
), ),
const SizedBox(width: 2), if (i < _options.length - 1)
Text( const Padding(
unit, padding: EdgeInsets.only(left: 50),
style: const TextStyle( child: Divider(height: 1, color: AppColors.divider),
fontSize: 11,
fontWeight: FontWeight.w800,
color: AppColors.textHint,
), ),
),
], ],
), ],
), ),
], ),
), ),
); );
if (selected != null) onChanged(selected);
} }
} }

View File

@@ -0,0 +1,156 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../core/app_colors.dart';
class DietPalette {
DietPalette._();
static const primary = Color(0xFF6476E8);
static const primarySoft = Color(0xFFEEF0FF);
static const protein = Color(0xFF38A77B);
static const carbs = Color(0xFF4E91D8);
static const fat = Color(0xFFE58A76);
static const calorie = Color(0xFF6D6FEA);
static const gradient = LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF8FA7FF), primary],
);
}
class DietCalorieRing extends StatelessWidget {
final int calories;
final double size;
const DietCalorieRing({super.key, required this.calories, this.size = 116});
@override
Widget build(BuildContext context) {
final progress = (calories / 800).clamp(0.04, 1.0);
return Semantics(
label: '总热量 $calories 千卡',
child: SizedBox.square(
dimension: size,
child: CustomPaint(
painter: _CalorieRingPainter(progress),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'$calories',
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const Text(
'千卡',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppColors.textHint,
),
),
],
),
),
),
);
}
}
class DietMacroBar extends StatelessWidget {
final String label;
final int value;
final Color color;
final double reference;
const DietMacroBar({
super.key,
required this.label,
required this.value,
required this.color,
required this.reference,
});
@override
Widget build(BuildContext context) {
final progress = (value / reference).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary,
),
),
const Spacer(),
Text(
'$value g',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
),
),
],
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(999),
child: LinearProgressIndicator(
minHeight: 5,
value: progress,
backgroundColor: color.withValues(alpha: 0.12),
valueColor: AlwaysStoppedAnimation(color),
),
),
],
);
}
}
class _CalorieRingPainter extends CustomPainter {
final double progress;
const _CalorieRingPainter(this.progress);
@override
void paint(Canvas canvas, Size size) {
final center = size.center(Offset.zero);
final radius = size.shortestSide / 2 - 8;
final track = Paint()
..color = DietPalette.primarySoft
..style = PaintingStyle.stroke
..strokeWidth = 9
..strokeCap = StrokeCap.round;
final progressPaint = Paint()
..shader = const LinearGradient(
colors: [Color(0xFF8FA7FF), DietPalette.calorie],
).createShader(Offset.zero & size)
..style = PaintingStyle.stroke
..strokeWidth = 9
..strokeCap = StrokeCap.round;
canvas.drawCircle(center, radius, track);
canvas.drawArc(
Rect.fromCircle(center: center, radius: radius),
-math.pi / 2,
math.pi * 2 * progress,
false,
progressPaint,
);
}
@override
bool shouldRepaint(covariant _CalorieRingPainter oldDelegate) =>
oldDelegate.progress != progress;
}

View File

@@ -30,7 +30,6 @@ class _HomePageState extends ConsumerState<HomePage>
final _scaffoldKey = GlobalKey<ScaffoldState>(); final _scaffoldKey = GlobalKey<ScaffoldState>();
double? _drawerDragStartX; double? _drawerDragStartX;
String? _pickedImagePath; String? _pickedImagePath;
int _lastMsgCount = 0;
Timer? _notificationTimer; Timer? _notificationTimer;
@override @override
@@ -53,16 +52,6 @@ class _HomePageState extends ConsumerState<HomePage>
} }
} }
@override
void didChangeMetrics() {
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
if (!_focusNode.hasFocus) return;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || !_scrollCtrl.hasClients) return;
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
});
}
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
@@ -89,7 +78,6 @@ class _HomePageState extends ConsumerState<HomePage>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final chatState = ref.watch(chatProvider);
final auth = ref.watch(authProvider); final auth = ref.watch(authProvider);
final user = auth.user; final user = auth.user;
@@ -112,15 +100,17 @@ class _HomePageState extends ConsumerState<HomePage>
} }
}); });
final currentCount = chatState.messages.length; ref.listen<int>(chatProvider.select((state) => state.messages.length), (
if (currentCount > _lastMsgCount) { previous,
current,
) {
if (current <= (previous ?? 0)) return;
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (_scrollCtrl.hasClients) { if (mounted && _scrollCtrl.hasClients) {
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
} }
}); });
} });
_lastMsgCount = currentCount;
return Scaffold( return Scaffold(
key: _scaffoldKey, key: _scaffoldKey,
@@ -136,10 +126,7 @@ class _HomePageState extends ConsumerState<HomePage>
child: Stack( child: Stack(
children: [ children: [
RepaintBoundary( RepaintBoundary(
child: ChatMessagesView( child: _HomeMessages(scrollCtrl: _scrollCtrl),
scrollCtrl: _scrollCtrl,
messages: chatState.messages,
),
), ),
Positioned( Positioned(
left: 0, left: 0,
@@ -592,6 +579,18 @@ class _HomePageState extends ConsumerState<HomePage>
} }
} }
class _HomeMessages extends ConsumerWidget {
final ScrollController scrollCtrl;
const _HomeMessages({required this.scrollCtrl});
@override
Widget build(BuildContext context, WidgetRef ref) {
final messages = ref.watch(chatProvider.select((state) => state.messages));
return ChatMessagesView(scrollCtrl: scrollCtrl, messages: messages);
}
}
class _HeaderIconButton extends StatelessWidget { class _HeaderIconButton extends StatelessWidget {
final IconData icon; final IconData icon;
final VoidCallback onTap; final VoidCallback onTap;

View File

@@ -71,7 +71,9 @@ class ChatMessagesView extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final chatState = ref.watch(chatProvider); final streaming = ref.watch(
chatProvider.select((state) => (state.isStreaming, state.thinkingText)),
);
if (messages.isEmpty) { if (messages.isEmpty) {
return Center( return Center(
@@ -107,10 +109,17 @@ class ChatMessagesView extends ConsumerWidget {
controller: scrollCtrl, controller: scrollCtrl,
reverse: false, reverse: false,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
cacheExtent: 180,
itemCount: messages.length, itemCount: messages.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final msg = messages[index]; final msg = messages[index];
return _buildMessageContent(context, ref, msg, chatState); return _buildMessageContent(
context,
ref,
msg,
streaming.$1,
streaming.$2 ?? '',
);
}, },
); );
} }
@@ -121,7 +130,8 @@ class ChatMessagesView extends ConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
ChatMessage msg, ChatMessage msg,
ChatState chatState, bool isStreaming,
String thinkingText,
) { ) {
switch (msg.type) { switch (msg.type) {
case MessageType.agentWelcome: case MessageType.agentWelcome:
@@ -134,12 +144,10 @@ class ChatMessagesView extends ConsumerWidget {
case MessageType.dataConfirm: case MessageType.dataConfirm:
return _buildDataConfirmCard(context, ref, msg); return _buildDataConfirmCard(context, ref, msg);
default: default:
if (!msg.isUser && if (!msg.isUser && isStreaming && msg.content.trim().isEmpty) {
chatState.isStreaming && return _buildThinkingBubble(context, thinkingText);
msg.content.trim().isEmpty) {
return _buildThinkingBubble(context, chatState.thinkingText);
} }
return _buildTextBubble(context, ref, msg, chatState); return _buildTextBubble(context, ref, msg);
} }
} }
@@ -1048,7 +1056,6 @@ class ChatMessagesView extends ConsumerWidget {
BuildContext context, BuildContext context,
WidgetRef ref, WidgetRef ref,
ChatMessage msg, ChatMessage msg,
ChatState? chatState,
) { ) {
final isUser = msg.isUser; final isUser = msg.isUser;
final imageUrl = msg.metadata?['imageUrl'] as String?; final imageUrl = msg.metadata?['imageUrl'] as String?;
@@ -1088,7 +1095,7 @@ class ChatMessagesView extends ConsumerWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: isUser ? AppTheme.primary : AppColors.cardBackground, color: isUser ? const Color(0xFF5F56FF) : AppColors.cardBackground,
borderRadius: BorderRadius.only( borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 18.6 : 3), topLeft: Radius.circular(isUser ? 18.6 : 3),
topRight: Radius.circular(isUser ? 3 : 18.6), topRight: Radius.circular(isUser ? 3 : 18.6),

View File

@@ -11,6 +11,7 @@ import '../../widgets/app_future_view.dart';
import '../../widgets/app_status_badge.dart'; import '../../widgets/app_status_badge.dart';
import '../../widgets/common_widgets.dart'; import '../../widgets/common_widgets.dart';
import '../../widgets/enterprise_widgets.dart'; import '../../widgets/enterprise_widgets.dart';
import '../../widgets/app_toast.dart';
class MedicationListPage extends ConsumerStatefulWidget { class MedicationListPage extends ConsumerStatefulWidget {
const MedicationListPage({super.key}); const MedicationListPage({super.key});
@@ -35,9 +36,16 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
} }
Future<void> _delete(String id) async { Future<void> _delete(String id) async {
await ref.read(medicationServiceProvider).deleteMedication(id); try {
ref.invalidate(medicationListProvider); await ref.read(medicationServiceProvider).deleteMedication(id);
_load(); ref.invalidate(medicationListProvider);
_load();
if (mounted) AppToast.show(context, '已删除', type: AppToastType.success);
} catch (_) {
if (mounted) {
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
}
}
} }
@override @override

View File

@@ -12,6 +12,7 @@ import '../widgets/common_widgets.dart';
import '../widgets/app_error_state.dart'; import '../widgets/app_error_state.dart';
import '../widgets/app_future_view.dart'; import '../widgets/app_future_view.dart';
import '../widgets/app_toast.dart'; import '../widgets/app_toast.dart';
import 'diet/diet_nutrition_widgets.dart';
import 'diet/diet_record_logic.dart'; import 'diet/diet_record_logic.dart';
/// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除) /// 饮食记录列表(趋势+日历+胶囊详情+侧滑编辑删除)
@@ -44,9 +45,19 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
} }
Future<void> _delete(String id) async { Future<void> _delete(String id) async {
await ref.read(dietServiceProvider).deleteRecord(id); final index = _data.indexWhere((d) => d['id']?.toString() == id);
if (mounted) { if (index < 0) return;
setState(() => _data.removeWhere((d) => d['id']?.toString() == id)); final removed = _data[index];
setState(() => _data.removeAt(index));
try {
await ref.read(dietServiceProvider).deleteRecord(id);
if (mounted) {
AppToast.show(context, '已删除', type: AppToastType.success);
}
} catch (_) {
if (!mounted) return;
setState(() => _data.insert(index.clamp(0, _data.length), removed));
AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error);
} }
} }
@@ -195,16 +206,16 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: isSel ? AppColors.dietGradient : null, gradient: isSel ? DietPalette.gradient : null,
color: isSel color: isSel
? null ? null
: (isToday ? AppColors.dietLight : Colors.white), : (isToday ? DietPalette.primarySoft : Colors.white),
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
border: Border.all( border: Border.all(
color: isSel color: isSel
? Colors.transparent ? Colors.transparent
: (isToday : (isToday
? AppColors.dietBorder ? const Color(0xFFC9D0FF)
: AppColors.borderLight), : AppColors.borderLight),
), ),
boxShadow: isSel ? AppColors.cardShadowLight : null, boxShadow: isSel ? AppColors.cardShadowLight : null,
@@ -220,7 +231,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
: ['', '', '', '', '', '', ''][d.weekday - : ['', '', '', '', '', '', ''][d.weekday -
1], 1],
style: TextStyle( style: TextStyle(
fontSize: 11, fontSize: 12,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: isSel ? Colors.white : AppColors.textHint, color: isSel ? Colors.white : AppColors.textHint,
), ),
@@ -240,7 +251,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text( Text(
'$cal', '$cal',
style: TextStyle( style: TextStyle(
fontSize: 10, fontSize: 11,
fontWeight: FontWeight.w600,
color: isSel ? Colors.white70 : AppColors.textHint, color: isSel ? Colors.white70 : AppColors.textHint,
), ),
), ),
@@ -280,6 +292,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
final id = record['id']?.toString() ?? ''; final id = record['id']?.toString() ?? '';
return _SwipeAction( return _SwipeAction(
itemKey: ValueKey(id),
onEdit: () => _showEditDialog(id, cal), onEdit: () => _showEditDialog(id, cal),
onDelete: () => _delete(id), onDelete: () => _delete(id),
child: Material( child: Material(
@@ -296,10 +309,14 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
width: 36, width: 36,
height: 36, height: 36,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.dietLight, color: DietPalette.primarySoft,
borderRadius: AppRadius.smBorder, borderRadius: AppRadius.smBorder,
), ),
child: Icon(mealIcon, size: 19, color: AppColors.diet), child: Icon(
mealIcon,
size: 19,
color: DietPalette.primary,
),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(
@@ -311,7 +328,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text( Text(
mealLabel, mealLabel,
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 17,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
@@ -320,7 +337,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
Text( Text(
'$cal 千卡', '$cal 千卡',
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 15,
fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
), ),
@@ -332,7 +350,8 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
child: Text( child: Text(
items.map((f) => f['name']).join(' · '), items.map((f) => f['name']).join(' · '),
style: const TextStyle( style: const TextStyle(
fontSize: 13, fontSize: 14,
fontWeight: FontWeight.w500,
color: AppColors.textHint, color: AppColors.textHint,
), ),
maxLines: 1, maxLines: 1,
@@ -412,7 +431,7 @@ class _DietDaySummary extends StatelessWidget {
gradient: const LinearGradient( gradient: const LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topLeft,
end: Alignment.bottomRight, end: Alignment.bottomRight,
colors: [Color(0xFFFFF8DB), Color(0xFFFFFFFF)], colors: [Color(0xFFF0F3FF), Color(0xFFFFFFFF)],
), ),
borderRadius: AppRadius.xlBorder, borderRadius: AppRadius.xlBorder,
boxShadow: AppColors.cardShadowLight, boxShadow: AppColors.cardShadowLight,
@@ -447,7 +466,7 @@ class _DietDaySummary extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: AppColors.dietGradient, gradient: DietPalette.gradient,
borderRadius: AppRadius.mdBorder, borderRadius: AppRadius.mdBorder,
), ),
child: const Icon( child: const Icon(
@@ -543,7 +562,7 @@ class _Toggle extends StatelessWidget {
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: a ? AppColors.dietGradient : null, gradient: a ? DietPalette.gradient : null,
color: a ? null : Colors.white, color: a ? null : Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
border: Border.all(color: a ? Colors.transparent : AppColors.border), border: Border.all(color: a ? Colors.transparent : AppColors.border),
@@ -599,7 +618,7 @@ class _TrendChart extends StatelessWidget {
Container( Container(
height: h, height: h,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: data[i] > 0 ? AppColors.dietGradient : null, gradient: data[i] > 0 ? DietPalette.gradient : null,
color: data[i] > 0 ? null : AppColors.border, color: data[i] > 0 ? null : AppColors.border,
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
@@ -614,20 +633,25 @@ class _TrendChart extends StatelessWidget {
} }
class _SwipeAction extends StatelessWidget { class _SwipeAction extends StatelessWidget {
final Key itemKey;
final Widget child; final Widget child;
final VoidCallback onEdit; final VoidCallback onEdit;
final VoidCallback onDelete; final VoidCallback onDelete;
const _SwipeAction({ const _SwipeAction({
required this.itemKey,
required this.child, required this.child,
required this.onEdit, required this.onEdit,
required this.onDelete, required this.onDelete,
}); });
@override @override
Widget build(BuildContext c) => Dismissible( Widget build(BuildContext c) => Dismissible(
key: UniqueKey(), key: itemKey,
direction: DismissDirection.endToStart, direction: DismissDirection.endToStart,
confirmDismiss: (_) async { confirmDismiss: (direction) async {
return false; // 右滑编辑:不消除,弹回,编辑由 onUpdate 触发
if (direction == DismissDirection.startToEnd) return false;
// 左滑删除放行onDismissed 会触发 onDelete
return true;
}, },
background: Container( background: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -703,11 +727,7 @@ class DietRecordDetailPage extends ConsumerWidget {
Container( Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 15), padding: const EdgeInsets.fromLTRB(16, 16, 16, 15),
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( color: Colors.white,
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFFFFF9E8), Colors.white],
),
borderRadius: AppRadius.xlBorder, borderRadius: AppRadius.xlBorder,
boxShadow: AppColors.cardShadowLight, boxShadow: AppColors.cardShadowLight,
), ),
@@ -716,20 +736,6 @@ class DietRecordDetailPage extends ConsumerWidget {
children: [ children: [
Row( Row(
children: [ children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
gradient: AppColors.dietGradient,
borderRadius: AppRadius.mdBorder,
),
child: const Icon(
Icons.restaurant_menu,
size: 21,
color: Colors.white,
),
),
const SizedBox(width: 12),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -752,25 +758,39 @@ class DietRecordDetailPage extends ConsumerWidget {
], ],
), ),
), ),
Text(
'$totalCal 千卡',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: Color(0xFF9A3412),
),
),
], ],
), ),
const SizedBox(height: 18), const SizedBox(height: 20),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
_DietDetailMetric(label: '蛋白质', value: '$protein g'), DietCalorieRing(calories: totalCal),
_DietDetailMetric(label: '碳水', value: '$carbs g'), const SizedBox(width: 20),
_DietDetailMetric(label: '脂肪', value: '$fat g'), Expanded(
_DietDetailMetric( child: Column(
label: '食物', children: [
value: '${items.length}', DietMacroBar(
label: '蛋白质',
value: protein,
color: DietPalette.protein,
reference: 50,
),
const SizedBox(height: 14),
DietMacroBar(
label: '碳水',
value: carbs,
color: DietPalette.carbs,
reference: 100,
),
const SizedBox(height: 14),
DietMacroBar(
label: '脂肪',
value: fat,
color: DietPalette.fat,
reference: 35,
),
],
),
), ),
], ],
), ),
@@ -783,14 +803,7 @@ class DietRecordDetailPage extends ConsumerWidget {
), ),
), ),
const SizedBox(height: 20), const SizedBox(height: 20),
const Text( const Text('识别结果', style: AppTextStyles.sectionTitle),
'识别结果',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 8), const SizedBox(height: 8),
ClipRRect( ClipRRect(
borderRadius: AppRadius.xlBorder, borderRadius: AppRadius.xlBorder,
@@ -810,8 +823,8 @@ class DietRecordDetailPage extends ConsumerWidget {
child: Text( child: Text(
items[i]['name']?.toString() ?? '', items[i]['name']?.toString() ?? '',
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: AppColors.textPrimary, color: AppColors.textPrimary,
), ),
), ),
@@ -822,7 +835,7 @@ class DietRecordDetailPage extends ConsumerWidget {
items[i]['portion']?.toString() ?? '', items[i]['portion']?.toString() ?? '',
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
color: AppColors.textSecondary, color: AppColors.textSecondary,
), ),
@@ -834,9 +847,9 @@ class DietRecordDetailPage extends ConsumerWidget {
'${items[i]['calories'] ?? 0} 千卡', '${items[i]['calories'] ?? 0} 千卡',
textAlign: TextAlign.right, textAlign: TextAlign.right,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontSize: 16,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w700,
color: Color(0xFF9A3412), color: DietPalette.calorie,
), ),
), ),
), ),
@@ -865,35 +878,6 @@ class DietRecordDetailPage extends ConsumerWidget {
} }
} }
class _DietDetailMetric extends StatelessWidget {
final String label;
final String value;
const _DietDetailMetric({required this.label, required this.value});
@override
Widget build(BuildContext context) => Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
color: AppColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
label,
style: const TextStyle(fontSize: 12, color: AppColors.textHint),
),
],
),
);
}
/// 运动计划列表(含打卡) /// 运动计划列表(含打卡)
class ExercisePlanPage extends ConsumerStatefulWidget { class ExercisePlanPage extends ConsumerStatefulWidget {
const ExercisePlanPage({super.key}); const ExercisePlanPage({super.key});

View File

@@ -10,11 +10,11 @@ import '../../providers/auth_provider.dart';
// 持久化到后端 /api/notification-prefs启动时自动拉取一次。 // 持久化到后端 /api/notification-prefs启动时自动拉取一次。
final notificationPrefsProvider = final notificationPrefsProvider =
NotifierProvider<NotificationPrefsNotifier, Map<String, bool>>( NotifierProvider<NotificationPrefsNotifier, Map<String, dynamic>>(
NotificationPrefsNotifier.new, NotificationPrefsNotifier.new,
); );
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> { class NotificationPrefsNotifier extends Notifier<Map<String, dynamic>> {
static const _defaults = { static const _defaults = {
'pushEnabled': true, 'pushEnabled': true,
'medication': true, 'medication': true,
@@ -22,8 +22,8 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
'followUp': true, 'followUp': true,
'aiReply': false, 'aiReply': false,
'dndEnabled': false, 'dndEnabled': false,
'dndStart': false, 'dndStartMinutes': 22 * 60,
'dndEnd': false, 'dndEndMinutes': 8 * 60,
// 健康录入提醒——总开关 + 5 子项 // 健康录入提醒——总开关 + 5 子项
'healthRecord': true, 'healthRecord': true,
'healthRecord.bp': true, 'healthRecord.bp': true,
@@ -34,7 +34,7 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
}; };
@override @override
Map<String, bool> build() { Map<String, dynamic> build() {
Future.microtask(_loadFromBackend); Future.microtask(_loadFromBackend);
return {..._defaults}; return {..._defaults};
} }
@@ -47,6 +47,11 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
if (data == null) return; if (data == null) return;
state = { state = {
...state, ...state,
'pushEnabled': (data['pushEnabled'] as bool?) ?? true,
'dndEnabled': (data['dndEnabled'] as bool?) ?? false,
'dndStartMinutes':
(data['dndStartMinutes'] as num?)?.toInt() ?? 22 * 60,
'dndEndMinutes': (data['dndEndMinutes'] as num?)?.toInt() ?? 8 * 60,
'medication': (data['medicationReminder'] as bool?) ?? true, 'medication': (data['medicationReminder'] as bool?) ?? true,
'healthAlert': (data['abnormalAlert'] as bool?) ?? true, 'healthAlert': (data['abnormalAlert'] as bool?) ?? true,
'followUp': (data['followUpReminder'] as bool?) ?? true, 'followUp': (data['followUpReminder'] as bool?) ?? true,
@@ -87,6 +92,8 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
'healthRecord.glucose' => 'healthRecordReminderGlucose', 'healthRecord.glucose' => 'healthRecordReminderGlucose',
'healthRecord.spo2' => 'healthRecordReminderSpO2', 'healthRecord.spo2' => 'healthRecordReminderSpO2',
'healthRecord.weight' => 'healthRecordReminderWeight', 'healthRecord.weight' => 'healthRecordReminderWeight',
'pushEnabled' => 'pushEnabled',
'dndEnabled' => 'dndEnabled',
_ => null, _ => null,
}; };
if (backendField == null) return; if (backendField == null) return;
@@ -99,12 +106,24 @@ class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
} }
} }
void setDndStart(TimeOfDay time) { Future<void> setDndStart(TimeOfDay time) async {
state = {...state, 'dndStart': true}; await _setTime('dndStartMinutes', time.hour * 60 + time.minute);
} }
void setDndEnd(TimeOfDay time) { Future<void> setDndEnd(TimeOfDay time) async {
state = {...state, 'dndEnd': true}; await _setTime('dndEndMinutes', time.hour * 60 + time.minute);
}
Future<void> _setTime(String key, int minutes) async {
final previous = state[key];
state = {...state, key: minutes};
try {
await ref
.read(apiClientProvider)
.put('/api/notification-prefs', data: {key: minutes});
} catch (_) {
state = {...state, key: previous};
}
} }
} }
@@ -116,7 +135,11 @@ class NotificationPrefsPage extends ConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final prefs = ref.watch(notificationPrefsProvider); final prefs = ref.watch(notificationPrefsProvider);
final dndOn = prefs['dndEnabled'] ?? false; final dndOn = prefs['dndEnabled'] as bool? ?? false;
final dndStart = prefs['dndStartMinutes'] as int? ?? 22 * 60;
final dndEnd = prefs['dndEndMinutes'] as int? ?? 8 * 60;
String formatMinutes(int value) =>
'${(value ~/ 60).toString().padLeft(2, '0')}:${(value % 60).toString().padLeft(2, '0')}';
return GradientScaffold( return GradientScaffold(
appBar: AppBar( appBar: AppBar(
@@ -278,7 +301,9 @@ class NotificationPrefsPage extends ConsumerWidget {
children: [ children: [
_SwitchTile( _SwitchTile(
title: '开启免打扰模式', title: '开启免打扰模式',
subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', subtitle: dndOn
? '${formatMinutes(dndStart)} - ${formatMinutes(dndEnd)} 期间静音'
: '关闭后全天接收通知',
value: dndOn, value: dndOn,
showDivider: dndOn, showDivider: dndOn,
onChanged: (v) => ref onChanged: (v) => ref
@@ -298,13 +323,13 @@ class NotificationPrefsPage extends ConsumerWidget {
Expanded( Expanded(
child: _TimeButton( child: _TimeButton(
label: '开始', label: '开始',
time: '22:00', time: formatMinutes(dndStart),
onTap: () async { onTap: () async {
final picked = await showAppTimePicker( final picked = await showAppTimePicker(
context, context,
initialTime: const TimeOfDay( initialTime: TimeOfDay(
hour: 22, hour: dndStart ~/ 60,
minute: 0, minute: dndStart % 60,
), ),
); );
if (picked != null && context.mounted) { if (picked != null && context.mounted) {
@@ -328,13 +353,13 @@ class NotificationPrefsPage extends ConsumerWidget {
Expanded( Expanded(
child: _TimeButton( child: _TimeButton(
label: '结束', label: '结束',
time: '08:00', time: formatMinutes(dndEnd),
onTap: () async { onTap: () async {
final picked = await showAppTimePicker( final picked = await showAppTimePicker(
context, context,
initialTime: const TimeOfDay( initialTime: TimeOfDay(
hour: 8, hour: dndEnd ~/ 60,
minute: 0, minute: dndEnd % 60,
), ),
); );
if (picked != null && context.mounted) { if (picked != null && context.mounted) {

View File

@@ -76,13 +76,13 @@ class ChatNotifier extends Notifier<ChatState> {
StreamSubscription<Map<String, dynamic>>? _subscription; StreamSubscription<Map<String, dynamic>>? _subscription;
Completer<void>? _streamDone; Completer<void>? _streamDone;
ActiveAgent? _lastTriggeredAgent; ActiveAgent? _lastTriggeredAgent;
Timer? _agentWelcomeTimer; Timer? _agentTapLockTimer;
String? _pendingAgentTriggerMessageId;
/// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。 /// 重置整个会话:取消正在进行的 SSE清空消息和会话 ID。
/// 历史记录页一键清空 / 删除当前会话时调用。 /// 历史记录页一键清空 / 删除当前会话时调用。
Future<void> resetSession() async { Future<void> resetSession() async {
await _cancelActiveStream(); await _cancelActiveStream();
_cancelPendingAgentWelcome();
_lastTriggeredAgent = null; _lastTriggeredAgent = null;
state = const ChatState(); state = const ChatState();
} }
@@ -133,7 +133,7 @@ class ChatNotifier extends Notifier<ChatState> {
ChatState build() { ChatState build() {
ref.onDispose(() { ref.onDispose(() {
_subscription?.cancel(); _subscription?.cancel();
_agentWelcomeTimer?.cancel(); _agentTapLockTimer?.cancel();
_subscription = null; _subscription = null;
if (_streamDone != null && !_streamDone!.isCompleted) { if (_streamDone != null && !_streamDone!.isCompleted) {
_streamDone!.complete(); _streamDone!.complete();
@@ -229,61 +229,41 @@ class ChatNotifier extends Notifier<ChatState> {
} }
} }
void insertAgentWelcome(ActiveAgent agent) { /// 点击胶囊:用户标签和欢迎卡片立即出现,不走 AI。
_pendingAgentTriggerMessageId = null; /// 300ms 点击锁只防止误触,不延迟界面反馈。
state = state.copyWith(
messages: [
...state.messages,
ChatMessage(
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.agentWelcome,
metadata: {'agent': agent.name},
),
],
);
}
/// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI
/// 重复点击同一胶囊不重复弹卡片 /// 重复点击同一胶囊不重复弹卡片
void triggerAgent(ActiveAgent agent, String label) { void triggerAgent(ActiveAgent agent, String label) {
if (_pendingAgentTriggerMessageId != null) { if (_agentTapLockTimer != null || _lastTriggeredAgent == agent) return;
return;
}
if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) {
return;
}
_lastTriggeredAgent = agent; _lastTriggeredAgent = agent;
final now = DateTime.now();
final userMsg = ChatMessage( final userMsg = ChatMessage(
id: 'agent_trigger_${DateTime.now().millisecondsSinceEpoch}', id: 'agent_trigger_${now.microsecondsSinceEpoch}',
role: 'user', role: 'user',
content: label, content: label,
createdAt: DateTime.now(), createdAt: now,
);
final welcomeMsg = ChatMessage(
id: 'welcome_${agent.name}_${now.microsecondsSinceEpoch}',
role: 'assistant',
content: '',
createdAt: now,
type: MessageType.agentWelcome,
metadata: {'agent': agent.name},
);
state = state.copyWith(
messages: [...state.messages, userMsg, welcomeMsg],
activeAgent: agent,
); );
final messages = state.messages.toList();
messages.add(userMsg);
_pendingAgentTriggerMessageId = userMsg.id;
state = state.copyWith(messages: messages, activeAgent: agent);
final expectedConversationId = state.conversationId;
_agentWelcomeTimer = Timer(Duration.zero, () { _agentTapLockTimer = Timer(const Duration(milliseconds: 300), () {
if (state.conversationId != expectedConversationId || _agentTapLockTimer = null;
state.activeAgent != agent ||
_lastTriggeredAgent != agent) {
return;
}
_agentWelcomeTimer = null;
insertAgentWelcome(agent);
}); });
} }
void _cancelPendingAgentWelcome() { void _cancelPendingAgentWelcome() {
_agentWelcomeTimer?.cancel(); _agentTapLockTimer?.cancel();
_agentWelcomeTimer = null; _agentTapLockTimer = null;
_pendingAgentTriggerMessageId = null;
} }
Future<void> sendImage(String imagePath, String text) async { Future<void> sendImage(String imagePath, String text) async {

View File

@@ -17,7 +17,7 @@ void main() {
expect(messages, isEmpty); expect(messages, isEmpty);
}); });
test('agent capsule inserts welcome when session is unchanged', () async { test('agent capsule inserts welcome immediately', () {
final container = ProviderContainer(); final container = ProviderContainer();
addTearDown(container.dispose); addTearDown(container.dispose);
@@ -25,8 +25,6 @@ void main() {
.read(chatProvider.notifier) .read(chatProvider.notifier)
.triggerAgent(ActiveAgent.diet, '营养助手'); .triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 450));
final messages = container.read(chatProvider).messages; final messages = container.read(chatProvider).messages;
expect(messages.map((m) => m.type), contains(MessageType.agentWelcome)); expect(messages.map((m) => m.type), contains(MessageType.agentWelcome));
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
@@ -40,11 +38,8 @@ void main() {
final notifier = container.read(chatProvider.notifier); final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手'); notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 120));
notifier.triggerAgent(ActiveAgent.medication, '药管家'); notifier.triggerAgent(ActiveAgent.medication, '药管家');
await Future<void>.delayed(const Duration(milliseconds: 450));
final welcomeAgents = container final welcomeAgents = container
.read(chatProvider) .read(chatProvider)
.messages .messages
@@ -56,4 +51,44 @@ void main() {
expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); expect(container.read(chatProvider).activeAgent, ActiveAgent.diet);
}, },
); );
test('repeated tap on the same agent does not add another card', () async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
expect(
container
.read(chatProvider)
.messages
.where((message) => message.type == MessageType.agentWelcome)
.length,
1,
);
});
test(
'different agent is accepted after the previous welcome appears',
() async {
final container = ProviderContainer();
addTearDown(container.dispose);
final notifier = container.read(chatProvider.notifier);
notifier.triggerAgent(ActiveAgent.diet, '营养助手');
await Future<void>.delayed(const Duration(milliseconds: 320));
notifier.triggerAgent(ActiveAgent.medication, '药管家');
expect(
container
.read(chatProvider)
.messages
.where((message) => message.type == MessageType.agentWelcome)
.map((message) => message.metadata?['agent']),
['diet', 'medication'],
);
},
);
} }