diff --git a/backend/src/Health.Application/Exercises/ExerciseContracts.cs b/backend/src/Health.Application/Exercises/ExerciseContracts.cs index 7788770..399711f 100644 --- a/backend/src/Health.Application/Exercises/ExerciseContracts.cs +++ b/backend/src/Health.Application/Exercises/ExerciseContracts.cs @@ -2,12 +2,6 @@ using Health.Domain.Entities; namespace Health.Application.Exercises; -public sealed record ExercisePlanItemInput( - DateOnly ScheduledDate, - string? ExerciseType, - int DurationMinutes, - bool IsRestDay); - public sealed record ExercisePlanCreateRequest( DateOnly StartDate, DateOnly EndDate, @@ -47,7 +41,6 @@ public interface IExerciseService { Task GetCurrentAsync(Guid userId, CancellationToken ct); Task CreateAsync(Guid userId, ExercisePlanCreateRequest request, CancellationToken ct); - Task CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList items, CancellationToken ct); Task> ListAsync(Guid userId, CancellationToken ct); Task GetByIdAsync(Guid userId, Guid planId, CancellationToken ct); Task DeleteAsync(Guid userId, Guid planId, CancellationToken ct); diff --git a/backend/src/Health.Application/Exercises/ExerciseService.cs b/backend/src/Health.Application/Exercises/ExerciseService.cs index e6e25f1..27810b9 100644 --- a/backend/src/Health.Application/Exercises/ExerciseService.cs +++ b/backend/src/Health.Application/Exercises/ExerciseService.cs @@ -41,26 +41,6 @@ public sealed class ExerciseService(IExerciseRepository exercises) : IExerciseSe return plan.Id; } - public async Task CreateFromItemsAsync(Guid userId, DateOnly startDate, DateOnly endDate, TimeOnly? reminderTime, IReadOnlyList items, CancellationToken ct) - { - foreach (var item in items) - ValidateDuration(item.DurationMinutes); - - var normalizedEnd = endDate < startDate ? startDate : endDate; - var plan = NewPlan(userId, startDate, normalizedEnd, reminderTime); - foreach (var item in items.Where(x => x.ScheduledDate >= startDate && x.ScheduledDate <= normalizedEnd).GroupBy(x => x.ScheduledDate).Select(x => x.First())) - { - plan.Items.Add(new ExercisePlanItem - { - Id = Guid.NewGuid(), ScheduledDate = item.ScheduledDate, - ExerciseType = string.IsNullOrWhiteSpace(item.ExerciseType) ? "散步" : item.ExerciseType.Trim(), - DurationMinutes = item.DurationMinutes > 0 ? item.DurationMinutes : 30, - IsRestDay = item.IsRestDay, - }); - } - await SaveNewAsync(plan, ct); - return plan.Id; - } // 时长上限校验:下限交由各方法兜底为 30,这里只拦截不合理的超大值 private static void ValidateDuration(int durationMinutes) diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs index 2fb65f0..f6d809a 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveContracts.cs @@ -37,7 +37,6 @@ public sealed record HealthArchiveUpdateRequest( public interface IHealthArchiveService { Task GetAsync(Guid userId, CancellationToken ct); - Task GetOrCreateAsync(Guid userId, CancellationToken ct); Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct); Task ExecuteAiActionAsync(Guid userId, string action, HealthArchiveUpdateRequest request, CancellationToken ct); } diff --git a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs index 1af1daf..d5811dc 100644 --- a/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs +++ b/backend/src/Health.Application/HealthArchives/HealthArchiveService.cs @@ -12,13 +12,6 @@ public sealed class HealthArchiveService(IHealthArchiveRepository archives) : IH return archive == null ? null : ToDto(archive); } - public async Task GetOrCreateAsync(Guid userId, CancellationToken ct) - { - var archive = await GetOrCreateEntityAsync(userId, ct); - await _archives.SaveChangesAsync(ct); - return ToDto(archive); - } - public async Task UpdateAsync(Guid userId, HealthArchiveUpdateRequest request, CancellationToken ct) { var archive = await GetOrCreateEntityAsync(userId, ct); diff --git a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs index 67a8c72..54f7d99 100644 --- a/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs +++ b/backend/src/Health.Infrastructure/AI/AgentHandlers/medication_agent_handler.cs @@ -33,21 +33,17 @@ public static class MedicationAgentHandler }; public static List Tools => [ManageMedicationTool, CommonAgentHandler.CheckArchiveTool]; - public static async Task Execute( string toolName, JsonElement args, - AppDbContext db, Guid userId, - IMedicationService? medications = null, + IMedicationService medications, CancellationToken ct = default) { return toolName switch { - "manage_medication" => medications != null - ? await ExecuteManageMedication(medications, userId, args, ct) - : await ExecuteManageMedication(db, userId, args), - _ => new { success = false, message = $"未知工具: {toolName}" } + "manage_medication" => await ExecuteManageMedication(medications, userId, args, ct), + _ => new { success = false, message = $"鏈煡宸ュ叿: {toolName}" } }; } @@ -111,82 +107,6 @@ public static class MedicationAgentHandler return success ? new { success = true } : new { success = false, message = "药品不存在或今天已经打卡" }; } - private static async Task ExecuteManageMedication(AppDbContext db, Guid userId, JsonElement args) - { - var action = args.TryGetProperty("action", out var a) ? a.GetString()! : "query"; - return action switch - { - "create" => await CreateMedication(db, userId, args), - "query" => await QueryMedications(db, userId), - "confirm" => await ConfirmMedication(db, userId, args), - _ => new { success = false, message = $"未知操作: {action}" } - }; - } - - private static async Task CreateMedication(AppDbContext db, Guid userId, JsonElement args) - { - var name = args.TryGetProperty("name", out var n) ? n.GetString()! : ""; - var dosage = args.TryGetProperty("dosage", out var dg) ? dg.GetString() : null; - var frequency = ReadFrequency(args); - var times = ReadTimes(args); - var durationDays = args.TryGetProperty("duration_days", out var dd) ? dd.GetInt32() : 0; - var startDate = ReadStartDate(args); - - var med = new Medication - { - Id = Guid.NewGuid(), - UserId = userId, - Name = name, - Dosage = dosage, - Frequency = frequency, - TimeOfDay = times.Count > 0 ? times : [new TimeOnly(8, 0)], - Source = MedicationSource.AiEntry, - IsActive = true, - StartDate = startDate, - EndDate = durationDays > 0 ? startDate.AddDays(durationDays) : null, - }; - db.Medications.Add(med); - await db.SaveChangesAsync(); - - var timeLabels = times.Count > 0 ? string.Join(", ", times.Select(t => t.ToString("HH:mm"))) : "08:00"; - return new - { - success = true, - medication_id = med.Id, - name = med.Name, - dosage = med.Dosage, - frequency = med.Frequency.ToString(), - time = timeLabels, - start_date = med.StartDate?.ToString("yyyy-MM-dd"), - duration_days = durationDays, - }; - } - - private static async Task QueryMedications(AppDbContext db, Guid userId) - { - var meds = await db.Medications.Where(m => m.UserId == userId && m.IsActive) - .Select(m => new { m.Id, m.Name, m.Dosage, m.TimeOfDay }).ToListAsync(); - return new { count = meds.Count, medications = meds }; - } - - private static async Task ConfirmMedication(AppDbContext db, Guid userId, JsonElement args) - { - var medId = args.TryGetProperty("medication_id", out var mid) ? mid.GetGuid() : Guid.Empty; - var med = await db.Medications.FirstOrDefaultAsync(m => m.Id == medId && m.UserId == userId); - if (med == null) return new { success = false, message = "药品不存在" }; - - db.MedicationLogs.Add(new MedicationLog - { - Id = Guid.NewGuid(), - MedicationId = medId, - UserId = userId, - Status = MedicationLogStatus.Taken, - ScheduledTime = TimeOnly.FromDateTime(DateTime.UtcNow.AddHours(8)), - ConfirmedAt = DateTime.UtcNow, - }); - await db.SaveChangesAsync(); - return new { success = true }; - } private static MedicationFrequency ReadFrequency(JsonElement args) { diff --git a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs index 27c2272..939c23b 100644 --- a/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs +++ b/backend/src/Health.Infrastructure/AI/AiToolExecutionService.cs @@ -34,7 +34,7 @@ public sealed class AiToolExecutionService( "record_health_data" => HealthDataAgentHandler.Execute(toolName, root, userId, _healthRecords), "query_health_records" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), "check_archive" => CommonAgentHandler.Execute(toolName, root, userId, _healthRecords, _healthArchives, ct), - "manage_medication" => MedicationAgentHandler.Execute(toolName, root, _db, userId, _medications, ct), + "manage_medication" => MedicationAgentHandler.Execute(toolName, root, userId, _medications, ct), "analyze_report" => ReportAgentHandler.AnalyzeReport(_db, userId, root, _visionClient), "manage_exercise" => ExerciseAgentHandler.Execute(toolName, root, _db, userId, _exercises, ct), "manage_archive" => CommonAgentHandler.ExecuteManageArchive(_healthArchives, userId, root, ct), diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index 45ff64e..5d5eca3 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -818,5 +818,3 @@ public static class AiChatEndpoints } } - -public sealed record ChatRequest(string Message, string? ConversationId); diff --git a/backend/tests/Health.Tests/persistence_pipeline_tests.cs b/backend/tests/Health.Tests/persistence_pipeline_tests.cs index 2852378..c90bddb 100644 --- a/backend/tests/Health.Tests/persistence_pipeline_tests.cs +++ b/backend/tests/Health.Tests/persistence_pipeline_tests.cs @@ -184,4 +184,5 @@ public sealed class PersistencePipelineTests new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options); + } diff --git a/health_app/lib/core/api_client.dart b/health_app/lib/core/api_client.dart index 81b035b..22fb71b 100644 --- a/health_app/lib/core/api_client.dart +++ b/health_app/lib/core/api_client.dart @@ -6,7 +6,7 @@ import 'local_database.dart'; /// API 基础地址。可通过 --dart-define=API_BASE_URL=http://host:5000 覆盖。 const String baseUrl = String.fromEnvironment( 'API_BASE_URL', - defaultValue: 'http://10.4.212.224:5000', + defaultValue: 'http://10.4.221.78:5000', ); class ApiException implements Exception { diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index afa637f..4075094 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -4,12 +4,30 @@ import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../widgets/app_toast.dart'; import '../../providers/data_providers.dart'; +const String defaultTrendMetricType = 'blood_pressure'; + +const Set supportedTrendMetricTypes = { + 'blood_pressure', + 'heart_rate', + 'glucose', + 'spo2', + 'weight', +}; + +String normalizeTrendMetricType(String? metricType) { + if (metricType == null) return defaultTrendMetricType; + return supportedTrendMetricTypes.contains(metricType) + ? metricType + : defaultTrendMetricType; +} + /// 健康概览趋势页 — 五大指标合到一个页面 class TrendPage extends ConsumerStatefulWidget { final String? metricType; // 可选初始选中指标 @@ -20,7 +38,7 @@ class TrendPage extends ConsumerStatefulWidget { } class _TrendPageState extends ConsumerState { - String _selected = 'blood_pressure'; + String _selected = defaultTrendMetricType; List> _allRecords = []; List> _filtered = []; bool _loading = true; @@ -84,12 +102,15 @@ class _TrendPageState extends ConsumerState { String get _name => _names[_selected] ?? ''; bool get _isBP => _selected == 'blood_pressure'; Color get _color => - _metrics.firstWhere((m) => m['key'] == _selected)['color'] as Color; + _metrics.firstWhere( + (m) => m['key'] == normalizeTrendMetricType(_selected), + )['color'] + as Color; @override void initState() { super.initState(); - if (widget.metricType != null) _selected = widget.metricType!; + _selected = normalizeTrendMetricType(widget.metricType); _loadAll(); } @@ -293,7 +314,10 @@ class _TrendPageState extends ConsumerState { Widget build(BuildContext context) { return GradientScaffold( appBar: AppBar( - leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), + leading: IconButton( + icon: const Icon(Icons.arrow_back), + onPressed: () => popRoute(ref), + ), title: const Text('健康概览'), centerTitle: true, ), @@ -431,8 +455,11 @@ class _TrendPageState extends ConsumerState { } // X 轴刻度间隔:日标签短,可以排密一点 - final xInterval = - spots.length > 60 ? 5.0 : spots.length > 30 ? 2.0 : 1.0; + final xInterval = spots.length > 60 + ? 5.0 + : spots.length > 30 + ? 2.0 + : 1.0; return Container( padding: const EdgeInsets.fromLTRB(8, 20, 16, 12), @@ -490,10 +517,10 @@ class _TrendPageState extends ConsumerState { ? (r['systolic'] as num?)?.toDouble() : (r['value'] as num?)?.toDouble(); if (v != null && spots.length > 1) { - final px = 40.0 + + final px = + 40.0 + (_selectedIdx! / (spots.length - 1)) * paintWidth; - final py = - paintHeight * (1 - (v - minV) / (maxV - minV)); + final py = paintHeight * (1 - (v - minV) / (maxV - minV)); final d = r['date'] as DateTime; tooltipPos = Offset(px, py); tooltipText1 = @@ -513,191 +540,198 @@ class _TrendPageState extends ConsumerState { minY: minV, maxY: maxV, gridData: const FlGridData(show: false), - borderData: FlBorderData(show: false), - titlesData: FlTitlesData( - leftTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 40, - interval: yStep, - getTitlesWidget: (v, meta) => Padding( - padding: const EdgeInsets.only(right: 6), - child: Text( - v.toStringAsFixed(0), - style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, - fontWeight: FontWeight.w500, - ), - ), - ), - ), - ), - bottomTitles: AxisTitles( - sideTitles: SideTitles( - showTitles: true, - reservedSize: 36, - interval: xInterval, - getTitlesWidget: (v, meta) { - final idx = v.toInt(); - if (idx < 0 || idx >= _filtered.length) { - return const SizedBox.shrink(); - } - final d = _filtered[idx]['date'] as DateTime; - // 当前可见刻度与上一个可见刻度相比,月份是否变化 - final prevIdx = idx - xInterval.toInt(); - final showMonth = idx == 0 || - prevIdx < 0 || - (_filtered[prevIdx]['date'] as DateTime).month != - d.month; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - '${d.day}', - style: const TextStyle( - fontSize: 12, - color: AppColors.textHint, - fontWeight: FontWeight.w500, - ), - ), - if (showMonth) ...[ - const SizedBox(height: 2), - Text( - '${d.month}月', - style: const TextStyle( - fontSize: 11, - color: AppColors.textSecondary, - fontWeight: FontWeight.w600, + borderData: FlBorderData(show: false), + titlesData: FlTitlesData( + leftTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 40, + interval: yStep, + getTitlesWidget: (v, meta) => Padding( + padding: const EdgeInsets.only(right: 6), + child: Text( + v.toStringAsFixed(0), + style: const TextStyle( + fontSize: 12, + color: AppColors.textHint, + fontWeight: FontWeight.w500, + ), ), ), - ], - ], - ); - }, - ), - ), - topTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - rightTitles: const AxisTitles( - sideTitles: SideTitles(showTitles: false), - ), - ), - lineBarsData: [ - LineChartBarData( - spots: spots, - isCurved: false, - color: _color, - barWidth: 2.5, - isStrokeCapRound: true, - dotData: FlDotData( - show: spots.length <= 30, - getDotPainter: (spot, _, _, _) { - final isSelected = spot.x.toInt() == _selectedIdx; - if (isSelected) { - return FlDotCirclePainter( - radius: 5.5, + ), + ), + bottomTitles: AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + interval: xInterval, + getTitlesWidget: (v, meta) { + final idx = v.toInt(); + if (idx < 0 || idx >= _filtered.length) { + return const SizedBox.shrink(); + } + final d = _filtered[idx]['date'] as DateTime; + // 当前可见刻度与上一个可见刻度相比,月份是否变化 + final prevIdx = idx - xInterval.toInt(); + final showMonth = + idx == 0 || + prevIdx < 0 || + (_filtered[prevIdx]['date'] as DateTime) + .month != + d.month; + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + '${d.day}', + style: const TextStyle( + fontSize: 12, + color: AppColors.textHint, + fontWeight: FontWeight.w500, + ), + ), + if (showMonth) ...[ + const SizedBox(height: 2), + Text( + '${d.month}月', + style: const TextStyle( + fontSize: 11, + color: AppColors.textSecondary, + fontWeight: FontWeight.w600, + ), + ), + ], + ], + ); + }, + ), + ), + topTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + rightTitles: const AxisTitles( + sideTitles: SideTitles(showTitles: false), + ), + ), + lineBarsData: [ + LineChartBarData( + spots: spots, + isCurved: false, color: _color, - strokeWidth: 2.5, - strokeColor: Colors.white, - ); - } - return FlDotCirclePainter( - radius: 3.5, - color: Colors.white, - strokeWidth: 2, - strokeColor: _color, - ); - }, - ), - belowBarData: BarAreaData( - show: true, - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - _color.withValues(alpha: 0.22), - _color.withValues(alpha: 0.02), + barWidth: 2.5, + isStrokeCapRound: true, + dotData: FlDotData( + show: spots.length <= 30, + getDotPainter: (spot, _, _, _) { + final isSelected = + spot.x.toInt() == _selectedIdx; + if (isSelected) { + return FlDotCirclePainter( + radius: 5.5, + color: _color, + strokeWidth: 2.5, + strokeColor: Colors.white, + ); + } + return FlDotCirclePainter( + radius: 3.5, + color: Colors.white, + strokeWidth: 2, + strokeColor: _color, + ); + }, + ), + belowBarData: BarAreaData( + show: true, + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + _color.withValues(alpha: 0.22), + _color.withValues(alpha: 0.02), + ], + ), + ), + shadow: Shadow( + color: _color.withValues(alpha: 0.18), + blurRadius: 6, + offset: const Offset(0, 3), + ), + ), ], - ), - ), - shadow: Shadow( - color: _color.withValues(alpha: 0.18), - blurRadius: 6, - offset: const Offset(0, 3), - ), - ), - ], - lineTouchData: LineTouchData( - handleBuiltInTouches: false, - touchCallback: (event, response) { - if (event is FlTapUpEvent) { - final spots = response?.lineBarSpots; - if (spots != null && spots.isNotEmpty) { - final idx = spots.first.x.toInt(); - setState(() => - _selectedIdx = (_selectedIdx == idx) ? null : idx); - } else { - setState(() => _selectedIdx = null); - } - } - }, - ), - ), - duration: Duration.zero, - ), - if (tooltipPos != null) - Positioned( - left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0), - top: tooltipPos.dy - 56, - child: FractionalTranslation( - translation: const Offset(-0.5, 0), - child: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, - vertical: 6, - ), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all( - color: _color.withValues(alpha: 0.35), - ), - boxShadow: [ - BoxShadow( - color: const Color(0xFF101828) - .withValues(alpha: 0.14), - blurRadius: 16, - offset: const Offset(0, 6), + lineTouchData: LineTouchData( + handleBuiltInTouches: false, + touchCallback: (event, response) { + if (event is FlTapUpEvent) { + final spots = response?.lineBarSpots; + if (spots != null && spots.isNotEmpty) { + final idx = spots.first.x.toInt(); + setState( + () => _selectedIdx = (_selectedIdx == idx) + ? null + : idx, + ); + } else { + setState(() => _selectedIdx = null); + } + } + }, ), - ], + ), + duration: Duration.zero, ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - tooltipText1!, - style: const TextStyle( - fontSize: 11, - color: AppColors.textHint, - fontWeight: FontWeight.w500, + if (tooltipPos != null) + Positioned( + left: tooltipPos.dx.clamp(60.0, chartWidth - 60.0), + top: tooltipPos.dy - 56, + child: FractionalTranslation( + translation: const Offset(-0.5, 0), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 6, + ), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(8), + border: Border.all( + color: _color.withValues(alpha: 0.35), + ), + boxShadow: [ + BoxShadow( + color: const Color( + 0xFF101828, + ).withValues(alpha: 0.14), + blurRadius: 16, + offset: const Offset(0, 6), + ), + ], + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + tooltipText1!, + style: const TextStyle( + fontSize: 11, + color: AppColors.textHint, + fontWeight: FontWeight.w500, + ), + ), + const SizedBox(height: 2), + Text( + tooltipText2!, + style: TextStyle( + fontSize: 15, + color: _color, + fontWeight: FontWeight.w700, + ), + ), + ], + ), ), ), - const SizedBox(height: 2), - Text( - tooltipText2!, - style: TextStyle( - fontSize: 15, - color: _color, - fontWeight: FontWeight.w700, - ), - ), - ], - ), - ), - ), - ), + ), ], ); }, @@ -731,137 +765,161 @@ class _TrendPageState extends ConsumerState { ], ), const SizedBox(height: 10), - ..._filtered.reversed.take(30).map((r) { - final date = r['date'] as DateTime; - final abnormal = r['isAbnormal'] == true; - final id = r['id']?.toString() ?? ''; - final idx = _filtered.indexOf(r); - final isSelected = idx == _selectedIdx; - String display; - if (_isBP) { - display = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; - } else { - display = '${r['value'] ?? '--'}'; - } - return Dismissible( - key: Key(id), - direction: DismissDirection.endToStart, - background: Container( - margin: const EdgeInsets.only(bottom: 6), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(12), - ), - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 20), - child: const Icon(Icons.delete_outline, color: Colors.white), - ), - confirmDismiss: (_) async { - try { - final api = ref.read(apiClientProvider); - await api.delete('/api/health-records/$id'); - setState(() { - _allRecords.removeWhere((x) => x['id']?.toString() == id); - _filtered.removeWhere((x) => x['id']?.toString() == id); - _selectedIdx = null; - }); - return true; - } catch (_) { - return false; - } - }, - child: GestureDetector( - onTap: () { - if (idx < 0) return; - setState(() => - _selectedIdx = isSelected ? null : idx); - }, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - margin: const EdgeInsets.only(bottom: 6), - padding: - const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - color: isSelected - ? _color.withValues(alpha: 0.15) - : Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: isSelected - ? _color.withValues(alpha: 0.55) - : AppColors.border, - width: isSelected ? 1.5 : 1, - ), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: _color.withAlpha(20), - borderRadius: BorderRadius.circular(10), - ), - child: Center( - child: Icon( - _getMetricIcon(_selected), - size: 21, - color: _color, - ), + Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + ), + child: Column( + children: [ + ..._filtered.reversed.take(30).toList().asMap().entries.map(( + entry, + ) { + final r = entry.value; + final isLast = + entry.key == _filtered.reversed.take(30).length - 1; + final date = r['date'] as DateTime; + final abnormal = r['isAbnormal'] == true; + final id = r['id']?.toString() ?? ''; + final idx = _filtered.indexOf(r); + final isSelected = idx == _selectedIdx; + String display; + if (_isBP) { + display = + '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'}'; + } else { + display = '${r['value'] ?? '--'}'; + } + return Dismissible( + key: Key(id), + direction: DismissDirection.endToStart, + background: Container( + color: AppColors.error, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 20), + child: const Icon( + Icons.delete_outline, + color: Colors.white, ), ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - '${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', - style: const TextStyle( - fontSize: 15, - color: AppColors.textHint, + confirmDismiss: (_) async { + try { + final api = ref.read(apiClientProvider); + await api.delete('/api/health-records/$id'); + setState(() { + _allRecords.removeWhere( + (x) => x['id']?.toString() == id, + ); + _filtered.removeWhere((x) => x['id']?.toString() == id); + _selectedIdx = null; + }); + return true; + } catch (_) { + return false; + } + }, + child: GestureDetector( + onTap: () { + if (idx < 0) return; + setState(() => _selectedIdx = isSelected ? null : idx); + }, + child: AnimatedContainer( + duration: const Duration(milliseconds: 180), + color: isSelected + ? _color.withValues(alpha: 0.10) + : Colors.white, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 14, + vertical: 12, + ), + child: Row( + children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: _color.withAlpha(20), + borderRadius: AppRadius.smBorder, + ), + child: Center( + child: Icon( + _getMetricIcon(_selected), + size: 21, + color: _color, + ), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + Text( + '${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}', + style: const TextStyle( + fontSize: 15, + color: AppColors.textHint, + ), + ), + const SizedBox(height: 2), + Text( + '$display $_unit', + style: TextStyle( + fontSize: 23, + fontWeight: FontWeight.w700, + color: abnormal + ? AppColors.error + : AppColors.textPrimary, + ), + ), + ], + ), + ), + if (abnormal) + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 3, + ), + decoration: BoxDecoration( + color: AppColors.errorLight, + borderRadius: AppRadius.xsBorder, + ), + child: const Text( + '异常', + style: TextStyle( + fontSize: 14, + color: AppColors.error, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), ), - ), - const SizedBox(height: 2), - Text( - '$display $_unit', - style: TextStyle( - fontSize: 23, - fontWeight: FontWeight.w700, - color: abnormal - ? AppColors.error - : AppColors.textPrimary, - ), - ), - ], + if (!isLast) + const Padding( + padding: EdgeInsets.only(left: 66), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], + ), ), ), - if (abnormal) - Container( - padding: const EdgeInsets.symmetric( - horizontal: 8, - vertical: 3, - ), - decoration: BoxDecoration( - color: AppColors.errorLight, - borderRadius: BorderRadius.circular(6), - ), - child: const Text( - '异常', - style: TextStyle( - fontSize: 14, - color: AppColors.error, - fontWeight: FontWeight.w500, - ), - ), - ), - ], - ), - ), - ), - ); - }), + ); + }), + ], + ), + ), ], ); } diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index b6205d4..f589316 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -206,6 +206,17 @@ class _DeviceScanPageState extends ConsumerState name: _deviceNameOf(result), ) .timeout(const Duration(seconds: 15)); + if (!HealthBleService.isSyncImplemented(boundDevice.type)) { + if (mounted) { + AppToast.show( + context, + '${boundDevice.type.label}数据同步暂未开通,暂不绑定', + type: AppToastType.info, + ); + unawaited(_startScan()); + } + return; + } await ref.read(omronDeviceProvider.notifier).bind(boundDevice); if (boundDevice.type == BleDeviceType.bloodPressure) { @@ -223,8 +234,6 @@ class _DeviceScanPageState extends ConsumerState await _showBloodPressureDialog(syncResult.reading); } } - } else if (mounted) { - popRoute(ref); } if (mounted) popRoute(ref); diff --git a/health_app/lib/pages/history/conversation_history_page.dart b/health_app/lib/pages/history/conversation_history_page.dart index a26f971..9742572 100644 --- a/health_app/lib/pages/history/conversation_history_page.dart +++ b/health_app/lib/pages/history/conversation_history_page.dart @@ -56,7 +56,7 @@ class ConversationHistoryPage extends ConsumerWidget { final item = list[index]; return _HistoryTile( item: item, - onTap: () => _openConversation(ref, item.id), + onTap: () => _openConversation(context, ref, item.id), onDelete: () => _deleteOne(context, ref, item.id), ); }, @@ -66,8 +66,18 @@ class ConversationHistoryPage extends ConsumerWidget { ); } - Future _openConversation(WidgetRef ref, String id) async { - await ref.read(chatProvider.notifier).loadConversation(id); + Future _openConversation( + BuildContext context, + WidgetRef ref, + String id, + ) async { + final error = await ref.read(chatProvider.notifier).loadConversation(id); + if (error != null) { + if (context.mounted) { + AppToast.show(context, error, type: AppToastType.error); + } + return; + } popRoute(ref); } diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index e5c99cf..820e344 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -78,7 +78,7 @@ class _HomePageState extends ConsumerState final imagePath = _pickedImagePath; if (text.isEmpty && imagePath == null) return; _textCtrl.clear(); - _focusNode.unfocus(); + _dismissKeyboard(); setState(() => _pickedImagePath = null); if (imagePath != null) { ref.read(chatProvider.notifier).sendImage(imagePath, text); @@ -145,9 +145,9 @@ class _HomePageState extends ConsumerState left: 0, top: 0, bottom: 0, - width: 44, + width: MediaQuery.sizeOf(context).width * 0.8, child: GestureDetector( - behavior: HitTestBehavior.opaque, + behavior: HitTestBehavior.translucent, onHorizontalDragStart: (details) { _drawerDragStartX = details.globalPosition.dx; }, @@ -255,16 +255,20 @@ class _HomePageState extends ConsumerState ]; Widget _buildAgentBar() { + final activeAgent = ref.watch( + chatProvider.select((state) => state.activeAgent), + ); return SizedBox( - height: 42, + height: 46, child: ListView.separated( scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 14), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 2), itemCount: _agentDefs.length, separatorBuilder: (_, _) => const SizedBox(width: 8), itemBuilder: (_, i) { final agent = _agentDefs[i]; final visual = _agentVisual(agent); + final selected = activeAgent == agent; return GestureDetector( onTap: () => ref .read(chatProvider.notifier) @@ -272,9 +276,14 @@ class _HomePageState extends ConsumerState child: Container( padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 9), decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.86), + color: Colors.white, borderRadius: BorderRadius.circular(999), - border: Border.all(color: Colors.white, width: 1.2), + border: Border.all( + color: selected + ? AppColors.auraIndigo.withValues(alpha: 0.42) + : const Color(0xFFD5DAE4), + width: selected ? 1.4 : 1.1, + ), ), child: Row( mainAxisSize: MainAxisSize.min, @@ -477,6 +486,7 @@ class _HomePageState extends ConsumerState } void _showAttachmentPicker(BuildContext context) { + _dismissKeyboard(); showModalBottomSheet( context: context, backgroundColor: Colors.white, @@ -496,6 +506,7 @@ class _HomePageState extends ConsumerState title: const Text('拍照'), onTap: () { Navigator.pop(ctx); + _dismissKeyboard(); _pickImage(ImageSource.camera); }, ), @@ -507,6 +518,7 @@ class _HomePageState extends ConsumerState title: const Text('从相册选择'), onTap: () { Navigator.pop(ctx); + _dismissKeyboard(); _pickImage(ImageSource.gallery); }, ), @@ -518,6 +530,7 @@ class _HomePageState extends ConsumerState title: const Text('上传 PDF'), onTap: () async { Navigator.pop(ctx); + _dismissKeyboard(); final result = await FilePicker.platform.pickFiles( type: FileType.custom, allowedExtensions: ['pdf'], @@ -532,6 +545,7 @@ class _HomePageState extends ConsumerState .read(chatProvider.notifier) .sendPdf(path, pdfFile.name, _textCtrl.text.trim()); _textCtrl.clear(); + _dismissKeyboard(); if (mounted) setState(() {}); }, ), @@ -543,17 +557,25 @@ class _HomePageState extends ConsumerState } Future _pickImage(ImageSource source) async { + _dismissKeyboard(); final picked = await ImagePicker().pickImage( source: source, imageQuality: 85, ); + _dismissKeyboard(); if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; setState(() => _pickedImagePath = picked.path); + WidgetsBinding.instance.addPostFrameCallback((_) => _dismissKeyboard()); } } + void _dismissKeyboard() { + _focusNode.unfocus(); + FocusManager.instance.primaryFocus?.unfocus(); + } + Future _pickFoodImage(ImageSource source) async { final picked = await ImagePicker().pickImage( source: source, diff --git a/health_app/lib/pages/home/widgets/chat_messages_view.dart b/health_app/lib/pages/home/widgets/chat_messages_view.dart index 800fdaf..fa82ad9 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -859,13 +859,13 @@ class ChatMessagesView extends ConsumerWidget { ), ], ), - child: msg.confirmed + child: msg.confirmed || msg.isReadOnly ? Container( decoration: BoxDecoration( gradient: AppColors.successButtonGradient, borderRadius: BorderRadius.circular(18), ), - child: const Row( + child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( @@ -873,10 +873,10 @@ class ChatMessagesView extends ConsumerWidget { size: 22, color: Colors.white, ), - SizedBox(width: 8), + const SizedBox(width: 8), Text( - '录入成功', - style: TextStyle( + msg.isReadOnly ? '历史记录' : '录入成功', + style: const TextStyle( fontSize: 19, fontWeight: FontWeight.w700, color: Colors.white, @@ -1236,9 +1236,7 @@ class ChatMessagesView extends ConsumerWidget { ClipRRect( borderRadius: BorderRadius.circular(12), child: InteractiveViewer( - child: path.startsWith('http') - ? Image.network(resolvedPath, fit: BoxFit.contain) - : resolvedPath.startsWith('http') + child: resolvedPath.startsWith('http') ? Image.network(resolvedPath, fit: BoxFit.contain) : Image.file(File(resolvedPath), fit: BoxFit.contain), ), @@ -1270,20 +1268,6 @@ class ChatMessagesView extends ConsumerWidget { return path; } - /// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染) - // ignore: unused_element - static String _cleanAiText(String text) { - var t = text; - // 移除 $1、$2 等占位符 - t = t.replaceAll(RegExp(r'\$\d+'), ''); - // 移除残留 HTML 标签 - t = t.replaceAll(RegExp(r'<[^>]+>'), ''); - // 行首 # 超过 3 个降为 ### - t = t.replaceAllMapped(RegExp(r'^#{4,}\s', multiLine: true), (_) => '### '); - // 压缩多余空行 - t = t.replaceAll(RegExp(r'\n{3,}'), '\n\n'); - return t.trim(); - } /// 处理 AI 回复里的 markdown 链接点击: /// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程 @@ -2098,77 +2082,3 @@ extension _AgentActionsExt on ActiveAgent { _agentActions[this] ?? [const _AgentAction(label: '开始对话', icon: Icons.chat_outlined)]; } - -// ════════════════════════════════════════════════════════════════ -// 可展开的 AI 建议小组件 -// ════════════════════════════════════════════════════════════════ - -class _ExpandableAdvice extends StatefulWidget { - final String advice; - const _ExpandableAdvice({required this.advice}); - - @override - State<_ExpandableAdvice> createState() => _ExpandableAdviceState(); -} - -class _ExpandableAdviceState extends State<_ExpandableAdvice> { - bool _expanded = false; - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: () => setState(() => _expanded = !_expanded), - child: Container( - width: double.infinity, - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE8E4FF), width: 0.8), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const Icon( - Icons.lightbulb_outline, - size: 19, - color: AppTheme.primary, - ), - const SizedBox(width: 6), - const Text( - 'AI 建议', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppTheme.primary, - ), - ), - const Spacer(), - Icon( - _expanded - ? Icons.keyboard_arrow_up - : Icons.keyboard_arrow_down, - size: 21, - color: AppColors.textHint, - ), - ], - ), - if (_expanded) ...[ - const SizedBox(height: 10), - Text( - widget.advice, - style: const TextStyle( - fontSize: 16, - color: Color(0xFF555555), - height: 1.6, - ), - ), - ], - ], - ), - ), - ); - } -} diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index 81ecb22..cc65c64 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -102,90 +102,32 @@ class _MedicationListPageState extends ConsumerState { ], ), const SizedBox(height: 10), - ...List.generate(list.length, (i) { - final m = list[i]; - final times = - (m['timeOfDay'] as List?) - ?.map((t) => t.toString().substring(0, 5)) - .join(' ') ?? - ''; - final isActive = m['isActive'] == true; - final id = m['id']?.toString() ?? ''; - return SwipeDeleteTile( - key: Key(id), - onDelete: () => _delete(id), - onTap: () => pushRoute(ref, 'medCheckIn', params: {'id': id}), - margin: const EdgeInsets.symmetric(vertical: 5), - child: Container( - padding: const EdgeInsets.all(AppTheme.sLg), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], + _MedicationListGroup( + children: List.generate(list.length, (i) { + final m = list[i]; + final times = + (m['timeOfDay'] as List?) + ?.map((t) => t.toString().substring(0, 5)) + .join(' ') ?? + ''; + final isActive = m['isActive'] == true; + final id = m['id']?.toString() ?? ''; + return SwipeDeleteTile( + key: Key(id), + onDelete: () => _delete(id), + onTap: () => + pushRoute(ref, 'medCheckIn', params: {'id': id}), + margin: EdgeInsets.zero, + child: _MedicationRow( + name: m['name']?.toString() ?? '', + subtitle: '${m['dosage'] ?? ''} $times', + isActive: isActive, + showDivider: i < list.length - 1, + visual: _medVisual, ), - child: Row( - children: [ - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - gradient: isActive - ? _medVisual.gradient - : AppColors.surfaceGradient, - borderRadius: AppRadius.mdBorder, - border: Border.all(color: AppColors.borderLight), - ), - child: Icon( - _medVisual.icon, - size: 25, - color: isActive ? Colors.white : AppTheme.textSub, - ), - ), - const SizedBox(width: AppTheme.sMd), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - m['name']?.toString() ?? '', - style: AppTextStyles.listTitle.copyWith( - color: isActive - ? AppTheme.text - : AppTheme.textSub, - ), - ), - if (!isActive) ...[ - const SizedBox(width: 6), - AppStatusBadge( - label: '已停', - color: _medVisual.color, - ), - ], - ], - ), - const SizedBox(height: 2), - Text( - '${m['dosage'] ?? ''} $times', - style: AppTextStyles.listSubtitle.copyWith( - color: AppTheme.textSub, - ), - ), - ], - ), - ), - Icon( - Icons.chevron_right, - size: 21, - color: AppColors.textHint, - ), - ], - ), - ), - ); - }), + ); + }), + ), ], ); }, @@ -193,3 +135,125 @@ class _MedicationListPageState extends ConsumerState { ); } } + +class _MedicationListGroup extends StatelessWidget { + final List children; + + const _MedicationListGroup({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + ), + child: Column(children: children), + ); + } +} + +class _MedicationRow extends StatelessWidget { + final String name; + final String subtitle; + final bool isActive; + final bool showDivider; + final AppModuleVisual visual; + + const _MedicationRow({ + required this.name, + required this.subtitle, + required this.isActive, + required this.showDivider, + required this.visual, + }); + + @override + Widget build(BuildContext context) { + return Container( + color: Colors.white, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppTheme.sLg, + vertical: 13, + ), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + gradient: isActive + ? visual.gradient + : AppColors.surfaceGradient, + borderRadius: AppRadius.mdBorder, + border: Border.all(color: AppColors.borderLight), + ), + child: Icon( + visual.icon, + size: 25, + color: isActive ? Colors.white : AppTheme.textSub, + ), + ), + const SizedBox(width: AppTheme.sMd), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: Text( + name, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.listTitle.copyWith( + color: isActive + ? AppTheme.text + : AppTheme.textSub, + ), + ), + ), + if (!isActive) ...[ + const SizedBox(width: 6), + AppStatusBadge(label: '已停', color: visual.color), + ], + ], + ), + const SizedBox(height: 2), + Text( + subtitle, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.listSubtitle.copyWith( + color: AppTheme.textSub, + ), + ), + ], + ), + ), + const Icon( + Icons.chevron_right, + size: 21, + color: AppColors.textHint, + ), + ], + ), + ), + if (showDivider) + const Padding( + padding: EdgeInsets.only(left: 74), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], + ), + ); + } +} diff --git a/health_app/lib/pages/notifications/notification_center_page.dart b/health_app/lib/pages/notifications/notification_center_page.dart index 5b2cd0d..db8f100 100644 --- a/health_app/lib/pages/notifications/notification_center_page.dart +++ b/health_app/lib/pages/notifications/notification_center_page.dart @@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; @@ -240,7 +241,12 @@ class _NotificationCenterPageState if (today.isNotEmpty) ...[ const _SectionTitle('今天'), const SizedBox(height: 10), - ...today.map(_buildDismissible), + _NotificationGroup( + children: [ + for (var i = 0; i < today.length; i++) + _buildDismissible(today[i], showDivider: i < today.length - 1), + ], + ), ], if (earlier.isNotEmpty) ...[ if (today.isNotEmpty) const SizedBox(height: 18), @@ -251,49 +257,81 @@ class _NotificationCenterPageState onTap: () => setState(() => _showEarlier = !_showEarlier), ), const SizedBox(height: 10), - if (_showEarlier) ...earlier.map(_buildDismissible), + if (_showEarlier) + _NotificationGroup( + children: [ + for (var i = 0; i < earlier.length; i++) + _buildDismissible( + earlier[i], + showDivider: i < earlier.length - 1, + ), + ], + ), ], ], ); } - Widget _buildDismissible(InAppNotification item) => Padding( - 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: 26), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(16), - ), - 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.w700, - ), + Widget _buildDismissible( + InAppNotification item, { + required bool showDivider, + }) => Dismissible( + key: ValueKey(item.id), + direction: DismissDirection.endToStart, + background: Container( + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 26), + color: AppColors.error, + 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.w700, ), - ], - ), - ), - onDismissed: (_) => _delete(item), - child: SizedBox( - height: 98, - child: _NotificationCard(item: item, onTap: () => _open(item)), + ), + ], ), ), + onDismissed: (_) => _delete(item), + child: _NotificationRow( + item: item, + showDivider: showDivider, + onTap: () => _open(item), + ), ); } +class _NotificationGroup extends StatelessWidget { + final List children; + + const _NotificationGroup({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + boxShadow: [ + BoxShadow( + color: const Color(0xFF101828).withValues(alpha: 0.035), + blurRadius: 14, + offset: const Offset(0, 6), + ), + ], + ), + child: Column(children: children), + ); + } +} + class _UnreadHint extends StatelessWidget { final int unreadCount; @@ -305,7 +343,7 @@ class _UnreadHint extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), decoration: BoxDecoration( color: const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, border: Border.all(color: const Color(0xFFFDE68A)), ), child: Row( @@ -359,23 +397,8 @@ class _SectionShell extends StatelessWidget { const _SectionShell({required this.child}); @override - Widget build(BuildContext context) => Container( - margin: const EdgeInsets.fromLTRB(0, 6, 0, 2), - padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - boxShadow: [ - BoxShadow( - color: const Color(0xFF101828).withValues(alpha: 0.035), - blurRadius: 10, - offset: const Offset(0, 4), - ), - ], - ), - child: child, - ); + Widget build(BuildContext context) => + Padding(padding: const EdgeInsets.fromLTRB(2, 12, 2, 8), child: child); } class _CollapsibleSectionTitle extends StatelessWidget { @@ -433,38 +456,31 @@ class _CollapsibleSectionTitle extends StatelessWidget { ); } -class _NotificationCard extends StatelessWidget { +class _NotificationRow extends StatelessWidget { final InAppNotification item; + final bool showDivider; final VoidCallback onTap; - const _NotificationCard({required this.item, required this.onTap}); + const _NotificationRow({ + required this.item, + required this.showDivider, + required this.onTap, + }); @override Widget build(BuildContext context) { final visual = _NotificationVisual.of(item); return Material( color: Colors.white, - borderRadius: BorderRadius.circular(16), child: InkWell( onTap: onTap, - borderRadius: BorderRadius.circular(16), child: Container( - height: 98, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all( - color: item.isRead - ? AppColors.borderLight - : visual.color.withValues(alpha: 0.32), - width: 1.1, - ), - boxShadow: [AppTheme.shadowLight], - ), - child: Stack( + constraints: const BoxConstraints(minHeight: 94), + color: Colors.white, + child: Column( children: [ Padding( - padding: const EdgeInsets.fromLTRB(14, 13, 12, 13), + padding: const EdgeInsets.fromLTRB(14, 13, 12, 12), child: Row( children: [ Stack( @@ -482,7 +498,7 @@ class _NotificationCard extends StatelessWidget { visual.lightColor, ], ), - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, border: Border.all( color: visual.color.withValues(alpha: 0.22), ), @@ -527,7 +543,7 @@ class _NotificationCard extends StatelessWidget { ), decoration: BoxDecoration( color: visual.lightColor, - borderRadius: BorderRadius.circular(999), + borderRadius: AppRadius.pillBorder, ), child: Text( visual.label, @@ -589,6 +605,15 @@ class _NotificationCard extends StatelessWidget { ], ), ), + if (showDivider) + const Padding( + padding: EdgeInsets.only(left: 75), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), ], ), ), diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 13efced..c47de96 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_markdown/flutter_markdown.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../core/app_colors.dart'; +import '../core/app_design_tokens.dart'; import '../core/app_module_visuals.dart'; import '../core/app_theme.dart'; import '../core/navigation_provider.dart'; @@ -721,11 +722,7 @@ class _ExercisePlanPageState extends ConsumerState { } catch (e) { debugPrint('[ExercisePlan] 打卡失败: $e'); if (mounted) { - AppToast.show( - context, - '只能打卡今天的运动任务', - type: AppToastType.error, - ); + AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error); } } finally { if (mounted) setState(() => _busyItems.remove(itemId)); @@ -1304,11 +1301,7 @@ class _ExercisePlanDetailPageState } catch (e) { debugPrint('[ExercisePlanDetail] 打卡失败: $e'); if (mounted) { - AppToast.show( - context, - '只能打卡今天的运动任务', - type: AppToastType.error, - ); + AppToast.show(context, '只能打卡今天的运动任务', type: AppToastType.error); } } finally { if (mounted) setState(() => _busyItems.remove(itemId)); @@ -2462,7 +2455,7 @@ class _HealthArchivePageState extends ConsumerState { height: 32, decoration: BoxDecoration( color: AppColors.errorLight, - borderRadius: BorderRadius.circular(8), + borderRadius: AppRadius.smBorder, ), child: const Icon( Icons.close, @@ -2506,12 +2499,10 @@ class _Section extends StatelessWidget { }; @override Widget build(BuildContext c) => Container( - padding: const EdgeInsets.all(18), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], + borderRadius: AppRadius.smBorder, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -2519,26 +2510,26 @@ class _Section extends StatelessWidget { Row( children: [ Container( - width: 36, - height: 36, + width: 30, + height: 30, decoration: BoxDecoration( color: _color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, ), - child: Icon(icon, size: 20, color: _color), + child: Icon(icon, size: 18, color: _color), ), const SizedBox(width: 10), Text( title, style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w700, + fontSize: 17, + fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), ], ), - const SizedBox(height: 16), + const SizedBox(height: 12), ...fields, ], ), @@ -2572,15 +2563,15 @@ class _F extends StatelessWidget { vertical: 12, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: Color(0xFF60A5FA)), ), ), @@ -2610,7 +2601,7 @@ class _GenderF extends StatelessWidget { padding: const EdgeInsets.all(4), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.smBorder, border: Border.all(color: AppColors.borderLight), ), child: Row( @@ -2641,7 +2632,7 @@ class _GenderChip extends StatelessWidget { alignment: Alignment.center, decoration: BoxDecoration( color: selected ? const Color(0xFF2563EB) : Colors.transparent, - borderRadius: BorderRadius.circular(9), + borderRadius: AppRadius.smBorder, ), child: Text( label, @@ -2685,15 +2676,15 @@ class _F2 extends StatelessWidget { vertical: 8, ), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: AppColors.borderLight), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, borderSide: const BorderSide(color: Color(0xFF60A5FA)), ), ), @@ -2723,7 +2714,7 @@ class _DateF extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), + borderRadius: AppRadius.smBorder, border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, @@ -2774,7 +2765,7 @@ class _DateF2 extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, border: Border.all(color: AppColors.borderLight), ), alignment: Alignment.centerLeft, @@ -2824,7 +2815,7 @@ class _AddBtn extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), decoration: BoxDecoration( color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(10), + borderRadius: AppRadius.smBorder, border: Border.all(color: const Color(0xFFBFDBFE)), ), child: const Row( @@ -3366,13 +3357,119 @@ class _Legend extends StatelessWidget { ); } +const Map _extraStaticTextTitles = { + 'personalInfoList': '个人信息收集清单', + 'thirdPartySdkList': '第三方 SDK 共享清单', +}; + +const Map _extraStaticTextContents = { + 'personalInfoList': '''## 个人信息收集清单 + +更新日期:2026年7月9日 + +本清单用于说明小脉健康 App 在当前功能范围内可能收集的个人信息类型、使用目的和使用场景。实际启用情况以正式部署配置和您实际使用的功能为准。 + +### 一、账号与登录信息 + +- 信息内容:手机号、登录状态、账号标识、验证码校验结果。 +- 使用目的:完成注册、登录、身份识别、账号安全校验和账号注销。 +- 使用场景:登录、注册、刷新登录状态、删除账号。 + +### 二、个人资料和健康档案 + +- 信息内容:姓名或昵称、性别、出生日期、头像、过敏史、慢病史、家族史、手术信息、饮食禁忌等。 +- 使用目的:建立健康档案,辅助 AI 健康咨询、医生咨询、报告解读和健康管理。 +- 使用场景:完善资料、健康档案、AI 对话、医生端查看。 + +### 三、健康数据 + +- 信息内容:血压、心率、血糖、血氧、体重、记录时间、数据来源、异常状态。 +- 使用目的:记录和展示健康趋势,生成健康提醒,辅助 AI 分析和医生咨询。 +- 使用场景:手动录入、蓝牙血压计同步、健康概览、通知提醒、AI 对话确认录入。 + +### 四、用药、运动、饮食和日历数据 + +- 信息内容:药品名称、剂量、频次、服药时间、打卡记录、运动计划、饮食记录、热量估算、日程提醒。 +- 使用目的:提供用药管理、运动计划、饮食识别、健康提醒和日历管理。 +- 使用场景:用药管理、服药打卡、运动计划、饮食拍照识别、通知中心。 + +### 五、报告、图片和文件信息 + +- 信息内容:您主动上传的检查报告图片、聊天图片、PDF 文件、AI 识别结果、报告摘要和审核状态。 +- 使用目的:完成报告管理、报告解读、图片识别、AI 对话附件分析。 +- 使用场景:上传报告、拍照识别、AI 聊天发送照片或 PDF。 + +### 六、蓝牙设备信息 + +- 信息内容:设备名称、设备标识、设备类型、服务 UUID、最近同步时间、短期去重记录。 +- 使用目的:发现、绑定和同步支持的健康设备。 +- 使用场景:蓝牙设备扫描、血压计绑定、血压数据同步。 + +### 七、设备、日志和网络信息 + +- 信息内容:设备型号、系统版本、App 版本、接口请求状态、错误信息、网络状态。 +- 使用目的:功能适配、问题排查、服务安全、性能优化和合规审计。 +- 使用场景:App 使用过程中的接口访问、异常处理、故障排查。 +''', + 'thirdPartySdkList': '''## 第三方 SDK 共享清单 + +更新日期:2026年7月9日 + +本清单用于说明小脉健康 App 当前可能接入或依赖的第三方 SDK、外部接口和服务能力。正式上线前会根据实际启用的服务商、域名和资质信息继续更新。 + +### 一、AI 大模型和视觉识别服务 + +- 使用目的:AI 健康咨询、报告解读、图片识别、文本生成和内容理解。 +- 可能共享的信息:您主动输入的对话内容、上传的图片或 PDF、健康档案摘要、需要 AI 分析的健康数据。 +- 使用场景:AI 对话、拍照识别、上传报告、报告解读。 +- 说明:实际服务商和接口地址以正式环境配置为准。 + +### 二、短信验证服务 + +- 使用目的:发送登录或注册验证码。 +- 可能共享的信息:手机号、验证码发送状态、必要的风控信息。 +- 使用场景:手机号登录、注册、身份验证。 +- 说明:当前正式短信服务仍待接入,正式启用后补充服务商信息。 + +### 三、文件存储或服务器存储服务 + +- 使用目的:保存您主动上传的报告、图片和 PDF 文件。 +- 可能共享的信息:文件内容、文件名称、上传时间、账号标识。 +- 使用场景:报告管理、AI 聊天附件、图片识别。 +- 说明:当前仍处于本地和开发环境阶段,正式服务器和域名确定后补充公网信息。 + +### 四、实时通信服务 + +- 使用目的:支持医生咨询消息、AI 流式回复和实时状态更新。 +- 可能共享的信息:消息内容、会话标识、发送时间、账号标识。 +- 使用场景:AI 对话、医生咨询、通知状态更新。 + +### 五、系统能力和开源组件 + +- 使用目的:蓝牙扫描、相机/相册选择、文件选择、图表展示、Markdown 渲染、网络请求等 App 基础能力。 +- 可能涉及的信息:蓝牙设备信息、您主动选择的图片或文件、网络请求状态。 +- 使用场景:蓝牙设备、上传照片、上传 PDF、健康趋势、合规文本展示。 + +我们不会向第三方出售您的个人信息。涉及个人信息处理的第三方服务,会在实现相关功能所必需的范围内使用,并在正式上线前结合实际服务商完善披露信息。 +''', +}; + +String staticTextTitle(String type) => _extraStaticTextTitles[type] ?? ''; + +String staticTextContent(String type) => _extraStaticTextContents[type] ?? ''; + /// 静态文本页 class StaticTextPage extends ConsumerWidget { final String type; const StaticTextPage({super.key, required this.type}); @override Widget build(BuildContext context, WidgetRef ref) { - final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于小脉健康'}; + final titles = { + 'privacy': '隐私协议', + 'terms': '服务协议', + 'about': '关于小脉健康', + ..._extraStaticTextTitles, + }; final contents = { 'privacy': '''## 小脉健康隐私政策 @@ -3676,6 +3773,7 @@ class StaticTextPage extends ConsumerWidget { ### 版权声明 © 2025-2026 小脉健康团队。保留所有权利。 本软件受中华人民共和国著作权法保护。''', + ..._extraStaticTextContents, }; return Scaffold( backgroundColor: Colors.white, diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index bad47f5..3144213 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -7,11 +7,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_module_visuals.dart'; import '../../core/app_theme.dart'; import '../../core/api_client.dart' show baseUrl; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; +import '../../widgets/common_widgets.dart'; import '../../widgets/enterprise_widgets.dart'; final reportProvider = NotifierProvider( @@ -447,8 +449,26 @@ class ReportListPage extends ConsumerWidget { if (state.reports.isEmpty) _buildEmptyState(context) else - ...state.reports.map( - (report) => _buildReportCard(context, ref, report), + _ReportListGroup( + children: [ + for (var i = 0; i < state.reports.length; i++) + SwipeDeleteTile( + key: Key(state.reports[i].id), + onDelete: () => ref + .read(reportProvider.notifier) + .deleteReport(state.reports[i].id), + onTap: () => pushRoute( + ref, + 'aiAnalysis', + params: {'id': state.reports[i].id}, + ), + margin: EdgeInsets.zero, + child: _buildReportRow( + state.reports[i], + showDivider: i < state.reports.length - 1, + ), + ), + ], ), ], ), @@ -461,7 +481,7 @@ class ReportListPage extends ConsumerWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: AppColors.error.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(14), + borderRadius: AppRadius.mdBorder, border: Border.all(color: AppColors.error.withValues(alpha: 0.22)), ), child: Row( @@ -576,7 +596,7 @@ class ReportListPage extends ConsumerWidget { height: 100, decoration: BoxDecoration( color: _reportBlue.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(50), + borderRadius: AppRadius.pillBorder, ), child: Icon(_reportVisual.icon, size: 44, color: _reportBlue), ), @@ -599,126 +619,93 @@ class ReportListPage extends ConsumerWidget { ); } - Widget _buildReportCard( - BuildContext context, - WidgetRef ref, - ReportItem report, - ) { + Widget _buildReportRow(ReportItem report, {required bool showDivider}) { final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title; - return Container( - margin: const EdgeInsets.only(bottom: 12), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), - child: Material( - color: Colors.transparent, - borderRadius: BorderRadius.circular(16), - child: InkWell( - onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}), - borderRadius: BorderRadius.circular(16), - child: Padding( - padding: const EdgeInsets.all(15), - child: Row( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: _reportBlue.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(14), - border: Border.all( + return Material( + color: Colors.white, + child: InkWell( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( color: _reportBlue.withValues(alpha: 0.10), + borderRadius: AppRadius.mdBorder, + ), + child: Icon( + _reportVisual.icon, + size: 24, + color: _reportBlue, ), ), - child: Icon(_reportVisual.icon, size: 26, color: _reportBlue), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - displayTitle, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 18, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - const SizedBox(height: 4), - Text( - _formatDate(report.uploadedAt), - style: const TextStyle( - fontSize: 14, - color: AppColors.textHint, - ), - ), - if (report.aiStatus == 'Failed') ...[ - const SizedBox(height: 4), + const SizedBox(width: 13), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ Text( - _failureSummary(report), - maxLines: 2, + displayTitle, + maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 13, - height: 1.35, - color: AppColors.error, - fontWeight: FontWeight.w600, + style: AppTextStyles.listTitle.copyWith( + fontWeight: FontWeight.w800, ), ), + const SizedBox(height: 3), + Text( + _formatDate(report.uploadedAt), + style: AppTextStyles.listSubtitle, + ), + if (report.aiStatus == 'Failed') ...[ + const SizedBox(height: 4), + Text( + _failureSummary(report), + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: const TextStyle( + fontSize: 13, + height: 1.35, + color: AppColors.error, + fontWeight: FontWeight.w600, + ), + ), + ], ], - ], + ), ), - ), - _buildStatusBadge(report), - const SizedBox(width: 4), - IconButton( - onPressed: () => _confirmDelete(context, ref, report.id), - visualDensity: VisualDensity.compact, - icon: const Icon( - Icons.delete_outline, - size: 20, + const SizedBox(width: 8), + _buildStatusBadge(report), + const SizedBox(width: 8), + const Icon( + Icons.chevron_right, + size: 21, color: AppColors.textHint, ), - ), - ], + ], + ), ), - ), + if (showDivider) + const Padding( + padding: EdgeInsets.only(left: 71), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], ), ), ); } - void _confirmDelete(BuildContext context, WidgetRef ref, String id) { - showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('删除报告'), - content: const Text('确定要删除这份报告吗?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx), - child: const Text('取消'), - ), - TextButton( - onPressed: () { - Navigator.pop(ctx); - ref.read(reportProvider.notifier).deleteReport(id); - }, - child: const Text('删除', style: TextStyle(color: AppColors.error)), - ), - ], - ), - ); - } - Widget _buildStatusBadge(ReportItem report) { final (label, bg, fg) = switch (report.status) { _ when report.reviewStatus == 'Reviewed' => ( @@ -748,7 +735,7 @@ class ReportListPage extends ConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: bg, - borderRadius: BorderRadius.circular(999), + borderRadius: AppRadius.pillBorder, border: Border.all(color: fg.withValues(alpha: 0.12)), ), child: Text( @@ -776,6 +763,24 @@ class ReportListPage extends ConsumerWidget { } } +class _ReportListGroup extends StatelessWidget { + final List children; + + const _ReportListGroup({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + ), + child: Column(children: children), + ); + } +} + String _catTitle(String c) => switch (c) { 'BloodTest' => '抽血化验单', 'Biochemistry' => '生化检验报告', @@ -786,20 +791,20 @@ String _catTitle(String c) => switch (c) { _ => '检查报告', }; -class ReportOriginalPage extends StatelessWidget { +class ReportOriginalPage extends ConsumerWidget { final String url; final String title; const ReportOriginalPage({super.key, required this.url, this.title = '原始报告'}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final imageUrl = _absoluteUrl(url); return GradientScaffold( appBar: AppBar( leading: IconButton( icon: const Icon(Icons.arrow_back), - onPressed: () => Navigator.pop(context), + onPressed: () => popRoute(ref), ), title: Text(title), ), diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index b34890a..57f76d6 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -144,191 +144,211 @@ class NotificationPrefsPage extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── 推送总开关 ── - _SectionTitle(title: '推送通知'), - _SwitchTile( - title: '允许推送通知', - subtitle: '关闭后将不再收到任何系统推送', - value: prefs['pushEnabled'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('pushEnabled'), + _SettingsListSection( + title: '推送通知', + children: [ + _SwitchTile( + title: '允许推送通知', + subtitle: '关闭后将不再收到任何系统推送', + value: prefs['pushEnabled'] ?? true, + showDivider: false, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('pushEnabled'), + ), + ], ), const SizedBox(height: 24), // ── 各类通知开关 ── - _SectionTitle(title: '通知类型'), - _SwitchTile( - icon: Icons.medication_rounded, - iconBg: AppColors.errorLight, - iconColor: AppColors.error, - title: '用药提醒', - subtitle: '服药时间到达时提醒您', - value: prefs['medication'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('medication'), - ), - _SwitchTile( - icon: Icons.warning_amber_rounded, - iconBg: AppColors.errorLight, - iconColor: AppTheme.error, - title: '健康异常提醒', - subtitle: '检测到数据异常时及时通知', - value: prefs['healthAlert'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('healthAlert'), - ), - _SwitchTile( - icon: Icons.event_available_rounded, - iconBg: AppColors.successLight, - iconColor: AppColors.success, - title: '复查日期提醒', - subtitle: '复查日前一天提醒您预约', - value: prefs['followUp'] ?? true, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('followUp'), - ), - _SwitchTile( - icon: Icons.forum_outlined, - iconBg: AppColors.iconBg, - iconColor: AppTheme.primary, - title: 'AI 回复通知', - subtitle: 'AI 助手回复时发送通知', - value: prefs['aiReply'] ?? false, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('aiReply'), + _SettingsListSection( + title: '通知类型', + children: [ + _SwitchTile( + icon: Icons.medication_rounded, + iconBg: AppColors.errorLight, + iconColor: AppColors.error, + title: '用药提醒', + subtitle: '服药时间到达时提醒您', + value: prefs['medication'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('medication'), + ), + _SwitchTile( + icon: Icons.warning_amber_rounded, + iconBg: AppColors.errorLight, + iconColor: AppTheme.error, + title: '健康异常提醒', + subtitle: '检测到数据异常时及时通知', + value: prefs['healthAlert'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthAlert'), + ), + _SwitchTile( + icon: Icons.event_available_rounded, + iconBg: AppColors.successLight, + iconColor: AppColors.success, + title: '复查日期提醒', + subtitle: '复查日前一天提醒您预约', + value: prefs['followUp'] ?? true, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('followUp'), + ), + _SwitchTile( + icon: Icons.forum_outlined, + iconBg: AppColors.iconBg, + iconColor: AppTheme.primary, + title: 'AI 回复通知', + subtitle: 'AI 助手回复时发送通知', + value: prefs['aiReply'] ?? false, + showDivider: false, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('aiReply'), + ), + ], ), 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'), + _SettingsListSection( + title: '健康录入提醒', + children: [ + _SwitchTile( + icon: Icons.alarm_on_rounded, + iconBg: const Color(0xFFFEF3C7), + iconColor: const Color(0xFFD97706), + title: '每日录入提醒', + subtitle: '每天上午提醒录入健康指标,已录入则不再打扰', + value: prefs['healthRecord'] ?? true, + showDivider: 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, + showDivider: false, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('healthRecord.weight'), + ), + ], + ], ), - 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( - title: '开启免打扰模式', - subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', - value: dndOn, - onChanged: (v) => ref - .read(notificationPrefsProvider.notifier) - .toggle('dndEnabled'), - ), - if (dndOn) ...[ - Container( - margin: const EdgeInsets.symmetric(horizontal: 4), - padding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 14, + _SettingsListSection( + title: '免打扰时段', + children: [ + _SwitchTile( + title: '开启免打扰模式', + subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', + value: dndOn, + showDivider: dndOn, + onChanged: (v) => ref + .read(notificationPrefsProvider.notifier) + .toggle('dndEnabled'), ), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( - children: [ - Expanded( - child: _TimeButton( - label: '开始', - time: '22:00', - onTap: () async { - final picked = await showAppTimePicker( - context, - initialTime: const TimeOfDay(hour: 22, minute: 0), - ); - if (picked != null && context.mounted) { - ref - .read(notificationPrefsProvider.notifier) - .setDndStart(picked); - } - }, - ), + if (dndOn) + Container( + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Text( - '~', - style: TextStyle( - fontSize: 19, - color: AppTheme.textHint, + decoration: BoxDecoration(color: AppTheme.surface), + child: Row( + children: [ + Expanded( + child: _TimeButton( + label: '开始', + time: '22:00', + onTap: () async { + final picked = await showAppTimePicker( + context, + initialTime: const TimeOfDay( + hour: 22, + minute: 0, + ), + ); + if (picked != null && context.mounted) { + ref + .read(notificationPrefsProvider.notifier) + .setDndStart(picked); + } + }, + ), ), - ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Text( + '~', + style: TextStyle( + fontSize: 19, + color: AppTheme.textHint, + ), + ), + ), + Expanded( + child: _TimeButton( + label: '结束', + time: '08:00', + onTap: () async { + final picked = await showAppTimePicker( + context, + initialTime: const TimeOfDay( + hour: 8, + minute: 0, + ), + ); + if (picked != null && context.mounted) { + ref + .read(notificationPrefsProvider.notifier) + .setDndEnd(picked); + } + }, + ), + ), + ], ), - Expanded( - child: _TimeButton( - label: '结束', - time: '08:00', - onTap: () async { - final picked = await showAppTimePicker( - context, - initialTime: const TimeOfDay(hour: 8, minute: 0), - ); - if (picked != null && context.mounted) { - ref - .read(notificationPrefsProvider.notifier) - .setDndEnd(picked); - } - }, - ), - ), - ], - ), - ), - const SizedBox(height: 8), - ], + ), + ], + ), const SizedBox(height: 40), ], ), @@ -358,6 +378,30 @@ class _SectionTitle extends StatelessWidget { } } +class _SettingsListSection extends StatelessWidget { + final String title; + final List children; + + const _SettingsListSection({required this.title, required this.children}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _SectionTitle(title: title), + ClipRRect( + borderRadius: BorderRadius.circular(AppTheme.rSm), + child: ColoredBox( + color: AppTheme.surface, + child: Column(children: children), + ), + ), + ], + ); + } +} + class _SwitchTile extends StatelessWidget { final IconData? icon; final Color? iconBg; @@ -365,6 +409,7 @@ class _SwitchTile extends StatelessWidget { final String title; final String? subtitle; final bool value; + final bool showDivider; final ValueChanged onChanged; const _SwitchTile({ @@ -374,33 +419,38 @@ class _SwitchTile extends StatelessWidget { required this.title, this.subtitle, required this.value, + this.showDivider = true, required this.onChanged, }); @override Widget build(BuildContext context) { return Container( - margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + margin: EdgeInsets.zero, + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14), decoration: BoxDecoration( color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: AppColors.border), - boxShadow: AppColors.cardShadowLight, + border: showDivider + ? Border( + bottom: BorderSide( + color: AppColors.borderLight.withValues(alpha: 0.75), + ), + ) + : null, ), child: Row( children: [ if (icon != null) ...[ Container( - width: 38, - height: 38, + width: 34, + height: 34, decoration: BoxDecoration( color: iconBg ?? AppColors.iconBg, - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(AppTheme.rSm), ), child: Icon( icon, - size: 23, + size: 20, color: iconColor ?? AppColors.primary, ), ), @@ -413,15 +463,17 @@ class _SwitchTile extends StatelessWidget { Text( title, style: const TextStyle( - fontSize: 18, + fontSize: 16, color: AppColors.textPrimary, - fontWeight: FontWeight.w500, + fontWeight: FontWeight.w700, ), ), + if (subtitle != null && subtitle!.isNotEmpty) + const SizedBox(height: 2), if (subtitle != null && subtitle!.isNotEmpty) Text( subtitle!, - style: TextStyle(fontSize: 15, color: AppTheme.textSub), + style: TextStyle(fontSize: 13, color: AppTheme.textSub), ), ], ), diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index c7a4421..0ddd538 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import '../../core/app_colors.dart'; +import '../../core/app_design_tokens.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; @@ -38,37 +39,58 @@ class SettingsPage extends ConsumerWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _SettingsTile( - icon: LucideIcons.bluetooth, - title: '蓝牙设备', - onTap: () => pushRoute(ref, 'devices'), - ), - const SizedBox(height: 8), - _SettingsTile( - icon: LucideIcons.bell, - title: '消息通知', - onTap: () => pushRoute(ref, 'notificationPrefs'), - ), - const SizedBox(height: 8), - _SettingsTile( - icon: LucideIcons.info, - title: '关于小脉健康', - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'about'}), - ), - const SizedBox(height: 8), - _SettingsTile( - icon: LucideIcons.shield, - title: '隐私协议', - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'privacy'}), - ), - const SizedBox(height: 8), - _SettingsTile( - icon: Icons.description_outlined, - title: '服务协议', - onTap: () => - pushRoute(ref, 'staticText', params: {'type': 'terms'}), + _SettingsGroup( + children: [ + _SettingsTile( + icon: LucideIcons.bluetooth, + title: '蓝牙设备', + onTap: () => pushRoute(ref, 'devices'), + ), + _SettingsTile( + icon: LucideIcons.bell, + title: '消息通知', + onTap: () => pushRoute(ref, 'notificationPrefs'), + ), + _SettingsTile( + icon: LucideIcons.info, + title: '关于小脉健康', + onTap: () => + pushRoute(ref, 'staticText', params: {'type': 'about'}), + ), + _SettingsTile( + icon: LucideIcons.shield, + title: '隐私协议', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'privacy'}, + ), + ), + _SettingsTile( + icon: Icons.fact_check_outlined, + title: '个人信息收集清单', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'personalInfoList'}, + ), + ), + _SettingsTile( + icon: Icons.hub_outlined, + title: '第三方 SDK 共享清单', + onTap: () => pushRoute( + ref, + 'staticText', + params: {'type': 'thirdPartySdkList'}, + ), + ), + _SettingsTile( + icon: Icons.description_outlined, + title: '服务协议', + onTap: () => + pushRoute(ref, 'staticText', params: {'type': 'terms'}), + ), + ], ), const SizedBox(height: 24), OutlinedButton.icon( @@ -172,6 +194,39 @@ class SettingsPage extends ConsumerWidget { } } +class _SettingsGroup extends StatelessWidget { + final List<_SettingsTile> children; + + const _SettingsGroup({required this.children}); + + @override + Widget build(BuildContext context) { + return Container( + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: AppRadius.xlBorder, + ), + child: Column( + children: [ + for (var i = 0; i < children.length; i++) ...[ + children[i], + if (i < children.length - 1) + const Padding( + padding: EdgeInsets.only(left: 52), + child: Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + ), + ], + ], + ), + ); + } +} + class _SettingsTile extends StatelessWidget { final IconData icon; final String title; @@ -186,17 +241,10 @@ class _SettingsTile extends StatelessWidget { Widget build(BuildContext context) { 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: Colors.black, size: 22), diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 3acd0c7..7e205e0 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -27,6 +27,7 @@ class ChatMessage { this.confirmed = false, }); bool get isUser => role == 'user'; + bool get isReadOnly => metadata?['readOnly'] == true; } enum ActiveAgent { @@ -67,16 +68,6 @@ class ChatState { ); } -class SelectedAgentNotifier extends Notifier { - @override - ActiveAgent? build() => null; - void select(ActiveAgent? a) => state = a; -} - -final selectedAgentProvider = - NotifierProvider( - SelectedAgentNotifier.new, - ); final chatProvider = NotifierProvider( ChatNotifier.new, ); @@ -85,8 +76,8 @@ class ChatNotifier extends Notifier { StreamSubscription>? _subscription; Completer? _streamDone; ActiveAgent? _lastTriggeredAgent; - - void markNeedsRebuild() => state = state.copyWith(); + Timer? _agentWelcomeTimer; + String? _pendingAgentTriggerMessageId; /// 重置整个会话:取消正在进行的 SSE,清空消息和会话 ID。 /// 历史记录页一键清空 / 删除当前会话时调用。 @@ -94,7 +85,6 @@ class ChatNotifier extends Notifier { await _cancelActiveStream(); _lastTriggeredAgent = null; state = const ChatState(); - ref.read(selectedAgentProvider.notifier).select(null); } /// 不可变消息操作方法(供 chat_messages_view 新版代码调用) @@ -102,6 +92,7 @@ class ChatNotifier extends Notifier { final msgs = state.messages.toList(); final i = msgs.indexWhere((m) => m.id == id); if (i < 0) return '确认卡片不存在'; + if (msgs[i].isReadOnly) return '历史记录中的录入卡片仅供查看'; final rawIds = msgs[i].metadata?['confirmationIds']; final confirmationIds = rawIds is List @@ -142,6 +133,7 @@ class ChatNotifier extends Notifier { ChatState build() { ref.onDispose(() { _subscription?.cancel(); + _agentWelcomeTimer?.cancel(); _subscription = null; if (_streamDone != null && !_streamDone!.isCompleted) { _streamDone!.complete(); @@ -170,14 +162,6 @@ class ChatNotifier extends Notifier { ); } - void setAgent(ActiveAgent a) { - // 流式回复中忽略胶囊切换,防止状态混乱 - if (state.isStreaming) return; - _cancelActiveStream(); - state = state.copyWith(activeAgent: a); - ref.read(selectedAgentProvider.notifier).select(a); - } - /// 根据 AI 调用的工具自动切换智能体胶囊 void _switchAgentByTool(String tool) { ActiveAgent? agent; @@ -203,13 +187,13 @@ class ChatNotifier extends Notifier { break; } if (agent != null) { - ref.read(selectedAgentProvider.notifier).select(agent); state = state.copyWith(activeAgent: agent); } } - Future loadConversation(String convId) async { + Future loadConversation(String convId) async { await _cancelActiveStream(); + _cancelPendingAgentWelcome(); try { final api = ref.read(apiClientProvider); final res = await api.get('/api/ai/conversations/$convId'); @@ -219,7 +203,8 @@ class ChatNotifier extends Notifier { final role = map['role']?.toString().toLowerCase() == 'user' ? 'user' : 'assistant'; - final metadata = _parseMetadata(map['metadataJson']); + final metadata = _parseMetadata(map['metadataJson']) ?? {}; + metadata['readOnly'] = true; return ChatMessage( id: map['id']?.toString() ?? '', role: role, @@ -229,6 +214,7 @@ class ChatNotifier extends Notifier { DateTime.now(), type: _messageTypeFromMetadata(metadata), metadata: metadata, + confirmed: metadata['confirmationIds'] is! List, ); }).toList(); @@ -237,11 +223,14 @@ class ChatNotifier extends Notifier { conversationId: convId, activeAgent: ActiveAgent.default_, ); - ref.read(selectedAgentProvider.notifier).select(ActiveAgent.default_); - } catch (_) {} + return null; + } catch (_) { + return '会话加载失败,请稍后重试'; + } } void insertAgentWelcome(ActiveAgent agent) { + _pendingAgentTriggerMessageId = null; state = state.copyWith( messages: [ ...state.messages, @@ -260,7 +249,12 @@ class ChatNotifier extends Notifier { /// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI /// 重复点击同一胶囊不重复弹卡片 void triggerAgent(ActiveAgent agent, String label) { - if (_lastTriggeredAgent == agent) return; + if (_pendingAgentTriggerMessageId != null) { + return; + } + if (_lastTriggeredAgent == agent && _pendingAgentTriggerMessageId == null) { + return; + } _lastTriggeredAgent = agent; final userMsg = ChatMessage( @@ -269,18 +263,35 @@ class ChatNotifier extends Notifier { content: label, createdAt: DateTime.now(), ); - state = state.copyWith(messages: [...state.messages, userMsg]); + final messages = state.messages.toList(); + messages.add(userMsg); + _pendingAgentTriggerMessageId = userMsg.id; + state = state.copyWith(messages: messages, activeAgent: agent); + final expectedConversationId = state.conversationId; - Future.delayed(const Duration(milliseconds: 400), () { + _agentWelcomeTimer = Timer(Duration.zero, () { + if (state.conversationId != expectedConversationId || + state.activeAgent != agent || + _lastTriggeredAgent != agent) { + return; + } + _agentWelcomeTimer = null; insertAgentWelcome(agent); }); } + void _cancelPendingAgentWelcome() { + _agentWelcomeTimer?.cancel(); + _agentWelcomeTimer = null; + _pendingAgentTriggerMessageId = null; + } + Future sendImage(String imagePath, String text) async { if (state.isStreaming) return; final file = File(imagePath); if (!await file.exists()) return; _lastTriggeredAgent = null; + _cancelPendingAgentWelcome(); // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( @@ -290,7 +301,10 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), metadata: {'localImagePath': imagePath}, ); - state = state.copyWith(messages: [...state.messages, userMsg]); + state = state.copyWith( + messages: [...state.messages, userMsg], + isStreaming: true, + ); // 异步上传图片 String? uploadedUrl; @@ -328,6 +342,7 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, errorMsg]); + state = state.copyWith(isStreaming: false); return; } @@ -342,6 +357,7 @@ class ChatNotifier extends Notifier { final file = File(pdfPath); if (!await file.exists()) return; _lastTriggeredAgent = null; + _cancelPendingAgentWelcome(); final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', @@ -350,7 +366,10 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), metadata: {'pdfFileName': fileName}, ); - state = state.copyWith(messages: [...state.messages, userMsg]); + state = state.copyWith( + messages: [...state.messages, userMsg], + isStreaming: true, + ); String? uploadedUrl; try { @@ -382,6 +401,7 @@ class ChatNotifier extends Notifier { createdAt: DateTime.now(), ); state = state.copyWith(messages: [...state.messages, errorMsg]); + state = state.copyWith(isStreaming: false); return; } @@ -391,6 +411,7 @@ class ChatNotifier extends Notifier { Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; _lastTriggeredAgent = null; + _cancelPendingAgentWelcome(); final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', diff --git a/health_app/lib/providers/conversation_history_provider.dart b/health_app/lib/providers/conversation_history_provider.dart index 209166b..ba20212 100644 --- a/health_app/lib/providers/conversation_history_provider.dart +++ b/health_app/lib/providers/conversation_history_provider.dart @@ -24,7 +24,8 @@ class ConversationListItem { title: json['title']?.toString(), summary: json['summary']?.toString(), messageCount: (json['messageCount'] as num?)?.toInt() ?? 0, - updatedAt: DateTime.tryParse(json['updatedAt']?.toString() ?? '') ?? + updatedAt: + DateTime.tryParse(json['updatedAt']?.toString() ?? '') ?? DateTime.now(), ); } @@ -33,15 +34,20 @@ class ConversationListItem { class ConversationHistoryNotifier extends AsyncNotifier> { @override - Future> build() => _fetch(); + Future> build() { + ref.watch(chatProvider.select((state) => state.conversationId)); + return _fetch(); + } Future> _fetch() async { final api = ref.read(apiClientProvider); final res = await api.get('/api/ai/conversations'); final raw = (res.data['data'] as List?) ?? const []; + final currentConversationId = ref.read(chatProvider).conversationId; return raw .whereType() .map((m) => ConversationListItem.fromJson(Map.from(m))) + .where((item) => item.id != currentConversationId) .toList(); } @@ -79,7 +85,8 @@ class ConversationHistoryNotifier } } -final conversationHistoryProvider = AsyncNotifierProvider< - ConversationHistoryNotifier, List>( - ConversationHistoryNotifier.new, -); +final conversationHistoryProvider = + AsyncNotifierProvider< + ConversationHistoryNotifier, + List + >(ConversationHistoryNotifier.new); diff --git a/health_app/lib/providers/data_providers.dart b/health_app/lib/providers/data_providers.dart index 8050851..64a5120 100644 --- a/health_app/lib/providers/data_providers.dart +++ b/health_app/lib/providers/data_providers.dart @@ -60,11 +60,6 @@ final latestHealthProvider = FutureProvider.autoDispose>(( return service.getLatest(); }); -/// AI 录入数据后调用,刷新侧边栏 -void refreshHealthData(WidgetRef ref) { - ref.invalidate(latestHealthProvider); - ref.invalidate(medicationListProvider); -} /// 用药列表 Provider final medicationListProvider = @@ -87,14 +82,6 @@ final doctorListProvider = FutureProvider>>(( return service.getDoctors().timeout(const Duration(seconds: 8)); }); -/// 问诊配额 Provider -final consultationQuotaProvider = FutureProvider>(( - ref, -) async { - final service = ref.watch(consultationServiceProvider); - return service.getQuota(); -}); - /// 当前运动计划 Provider final currentExercisePlanProvider = FutureProvider.autoDispose?>((ref) async { diff --git a/health_app/lib/providers/omron_device_provider.dart b/health_app/lib/providers/omron_device_provider.dart index 7e222b2..c4d0543 100644 --- a/health_app/lib/providers/omron_device_provider.dart +++ b/health_app/lib/providers/omron_device_provider.dart @@ -21,10 +21,6 @@ final healthBleServiceProvider = Provider((ref) { return service; }); -// Keep the old provider name as a compatibility alias while the feature is -// being migrated away from the Omron-specific naming. -final omronBleServiceProvider = healthBleServiceProvider; - class DeviceBindState { final List devices; final bool isConnected; diff --git a/health_app/lib/services/health_ble_service.dart b/health_app/lib/services/health_ble_service.dart index 3956eab..148db88 100644 --- a/health_app/lib/services/health_ble_service.dart +++ b/health_app/lib/services/health_ble_service.dart @@ -35,6 +35,10 @@ class HealthBleService { return BleDeviceIdentifier.typeFromScan(result); } + static bool isSyncImplemented(BleDeviceType type) { + return type == BleDeviceType.bloodPressure; + } + Future connectAndIdentify({ required BluetoothDevice device, required String name, @@ -65,6 +69,9 @@ class HealthBleService { if (connectedType != bound.type) { throw const UnsupportedBleDeviceException('设备类型和绑定信息不一致'); } + if (!isSyncImplemented(bound.type)) { + throw UnsupportedBleDeviceException('${bound.type.label}数据同步暂未开通'); + } return switch (bound.type) { BleDeviceType.bloodPressure => BloodPressureSyncResult( diff --git a/health_app/lib/services/health_service.dart b/health_app/lib/services/health_service.dart index 74d1f1d..8e24ef0 100644 --- a/health_app/lib/services/health_service.dart +++ b/health_app/lib/services/health_service.dart @@ -1,40 +1,15 @@ import '../core/api_client.dart'; -/// 健康数据服务 class HealthService { final ApiClient _api; HealthService(this._api); - /// 获取各指标最新值 Future> getLatest() async { final res = await _api.get('/api/health-records/latest'); return res.data['data'] ?? {}; } - - /// 获取趋势数据 - Future>> getTrend(String type, {int period = 7}) async { - final res = await _api.get('/api/health-records/trend', queryParameters: {'type': type, 'period': period}); - final list = res.data['data'] as List? ?? []; - return list.cast>(); - } - - /// 获取记录列表 - Future>> getRecords({String? type, int? days}) async { - final params = {}; - if (type != null) params['type'] = type; - if (days != null) params['days'] = days; - final res = await _api.get('/api/health-records', queryParameters: params); - final list = res.data['data'] as List? ?? []; - return list.cast>(); - } - - /// 删除记录 - Future deleteRecord(String id) async { - await _api.delete('/api/health-records/$id'); - } } -/// 用户服务 class UserService { final ApiClient _api; UserService(this._api); @@ -44,8 +19,15 @@ class UserService { return res.data['data']; } - Future updateProfile({String? name, String? gender, String? birthDate}) async { - await _api.put('/api/user/profile', data: {'name': name, 'gender': gender, 'birthDate': birthDate}); + Future updateProfile({ + String? name, + String? gender, + String? birthDate, + }) async { + await _api.put( + '/api/user/profile', + data: {'name': name, 'gender': gender, 'birthDate': birthDate}, + ); } Future?> getHealthArchive() async { @@ -62,7 +44,6 @@ class UserService { } } -/// 用药服务 class MedicationService { final ApiClient _api; MedicationService(this._api); @@ -81,14 +62,13 @@ class MedicationService { await _api.put('/api/medications/$id', data: data); } - Future delete(String id) async { - await _api.delete('/api/medications/$id'); - } - Future>> getMedications(String filter) async { final params = {}; if (filter.isNotEmpty) params['filter'] = filter; - final res = await _api.get('/api/medications', queryParameters: params.isNotEmpty ? params : null); + final res = await _api.get( + '/api/medications', + queryParameters: params.isNotEmpty ? params : null, + ); final list = res.data['data'] as List? ?? []; return list.cast>(); } @@ -108,12 +88,14 @@ class MedicationService { } } -/// 饮食服务 class DietService { final ApiClient _api; DietService(this._api); - Future>> getRecords({String? date, String? mealType}) async { + Future>> getRecords({ + String? date, + String? mealType, + }) async { final params = {}; if (date != null) params['date'] = date; if (mealType != null) params['mealType'] = mealType; @@ -135,7 +117,6 @@ class DietService { } } -/// 问诊服务 class ConsultationService { final ApiClient _api; ConsultationService(this._api); @@ -145,31 +126,8 @@ class ConsultationService { final list = res.data['data'] as List? ?? []; return list.cast>(); } - - Future> getQuota() async { - final res = await _api.get('/api/user/consultation-quota'); - return res.data['data'] ?? {}; - } - - Future> createConsultation(String doctorId) async { - final res = await _api.post('/api/consultations', data: {'doctorId': doctorId}); - return res.data['data']; - } - - Future>> getMessages(String consultationId, {String? after}) async { - final params = {}; - if (after != null) params['after'] = after; - final res = await _api.get('/api/consultations/$consultationId/messages', queryParameters: params); - final list = res.data['data'] as List? ?? []; - return list.cast>(); - } - - Future sendMessage(String consultationId, String content) async { - await _api.post('/api/consultations/$consultationId/messages', data: {'content': content}); - } } -/// 随访服务 class FollowUpService { final ApiClient _api; FollowUpService(this._api); @@ -181,7 +139,6 @@ class FollowUpService { } } -/// 运动服务 class ExerciseService { final ApiClient _api; ExerciseService(this._api); @@ -191,10 +148,6 @@ class ExerciseService { return res.data['data']; } - Future createPlan(Map data) async { - await _api.post('/api/exercise-plans', data: data); - } - Future createPlanSimple(Map data) async { await _api.post('/api/exercise-plans', data: data); } diff --git a/health_app/lib/widgets/app_buttons.dart b/health_app/lib/widgets/app_buttons.dart deleted file mode 100644 index 403106c..0000000 --- a/health_app/lib/widgets/app_buttons.dart +++ /dev/null @@ -1,242 +0,0 @@ -import 'package:flutter/material.dart'; - -import '../core/app_colors.dart'; -import '../core/app_design_tokens.dart'; - -enum AppButtonVariant { primary, secondary, outline, ghost, danger, gradientOutline } - -class AppButton extends StatelessWidget { - final String label; - final VoidCallback? onPressed; - final IconData? icon; - final AppButtonVariant variant; - final Color? color; - final LinearGradient? gradient; - final bool loading; - final double height; - final bool expand; - - const AppButton({ - super.key, - required this.label, - this.onPressed, - this.icon, - this.variant = AppButtonVariant.primary, - this.color, - this.gradient, - this.loading = false, - this.height = 50, - this.expand = true, - }); - - @override - Widget build(BuildContext context) { - final enabled = onPressed != null && !loading; - final baseColor = color ?? AppColors.primary; - final child = _ButtonContent( - label: label, - icon: icon, - loading: loading, - foreground: _foreground(baseColor), - ); - - if (variant == AppButtonVariant.gradientOutline) { - return _GradientOutlineButton( - label: label, - icon: icon, - loading: loading, - enabled: enabled, - height: height, - expand: expand, - gradient: gradient ?? AppColors.actionOutlineGradient, - onPressed: onPressed, - ); - } - - final decoration = _decoration(baseColor, enabled); - final width = expand ? double.infinity : null; - return SizedBox( - height: height, - width: width, - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: enabled ? onPressed : null, - borderRadius: AppRadius.lgBorder, - child: Ink( - decoration: decoration, - child: Center(child: child), - ), - ), - ), - ); - } - - Color _foreground(Color baseColor) { - return switch (variant) { - AppButtonVariant.primary => Colors.white, - AppButtonVariant.secondary => baseColor, - AppButtonVariant.outline => baseColor, - AppButtonVariant.ghost => baseColor, - AppButtonVariant.danger => variant == AppButtonVariant.danger && color == null - ? AppColors.error - : Colors.white, - AppButtonVariant.gradientOutline => AppColors.textPrimary, - }; - } - - BoxDecoration _decoration(Color baseColor, bool enabled) { - final disabledColor = const Color(0xFFE5E7EB); - return switch (variant) { - AppButtonVariant.primary => BoxDecoration( - color: enabled ? baseColor : disabledColor, - borderRadius: AppRadius.lgBorder, - boxShadow: enabled ? AppShadows.soft : AppShadows.none, - ), - AppButtonVariant.secondary => BoxDecoration( - color: enabled ? baseColor.withValues(alpha: 0.10) : disabledColor, - borderRadius: AppRadius.lgBorder, - ), - AppButtonVariant.outline => BoxDecoration( - color: Colors.white, - borderRadius: AppRadius.lgBorder, - border: Border.all(color: enabled ? baseColor.withValues(alpha: 0.38) : disabledColor), - ), - AppButtonVariant.ghost => BoxDecoration( - color: enabled ? baseColor.withValues(alpha: 0.08) : disabledColor, - borderRadius: AppRadius.lgBorder, - ), - AppButtonVariant.danger => BoxDecoration( - color: enabled ? (color ?? AppColors.error) : disabledColor, - borderRadius: AppRadius.lgBorder, - ), - AppButtonVariant.gradientOutline => const BoxDecoration(), - }; - } -} - -class _ButtonContent extends StatelessWidget { - final String label; - final IconData? icon; - final bool loading; - final Color foreground; - - const _ButtonContent({ - required this.label, - required this.icon, - required this.loading, - required this.foreground, - }); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.center, - mainAxisSize: MainAxisSize.min, - children: [ - if (loading) - SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2, color: foreground), - ) - else if (icon != null) - Icon(icon, size: 19, color: foreground), - if (loading || icon != null) const SizedBox(width: 8), - Flexible( - child: Text( - label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: AppTextStyles.button.copyWith(color: foreground), - ), - ), - ], - ); - } -} - -class _GradientOutlineButton extends StatelessWidget { - final String label; - final IconData? icon; - final bool loading; - final bool enabled; - final double height; - final bool expand; - final LinearGradient gradient; - final VoidCallback? onPressed; - - const _GradientOutlineButton({ - required this.label, - required this.icon, - required this.loading, - required this.enabled, - required this.height, - required this.expand, - required this.gradient, - required this.onPressed, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: height, - width: expand ? double.infinity : null, - padding: const EdgeInsets.all(1.5), - decoration: BoxDecoration( - gradient: enabled ? gradient : null, - color: enabled ? null : AppColors.borderLight, - borderRadius: AppRadius.lgBorder, - boxShadow: enabled ? AppShadows.soft : AppShadows.none, - ), - child: Material( - color: Colors.white, - borderRadius: BorderRadius.circular(AppRadius.lg - 1.5), - child: InkWell( - onTap: enabled ? onPressed : null, - borderRadius: BorderRadius.circular(AppRadius.lg - 1.5), - child: Center( - child: _ButtonContent( - label: label, - icon: icon, - loading: loading, - foreground: AppColors.textPrimary, - ), - ), - ), - ), - ); - } -} - -class AppIconTile extends StatelessWidget { - final IconData icon; - final Color color; - final Color backgroundColor; - final double size; - final double iconSize; - final BorderRadius? borderRadius; - - const AppIconTile({ - super.key, - required this.icon, - required this.color, - required this.backgroundColor, - this.size = 44, - this.iconSize = 22, - this.borderRadius, - }); - - @override - Widget build(BuildContext context) => Container( - width: size, - height: size, - decoration: BoxDecoration( - color: backgroundColor, - borderRadius: borderRadius ?? AppRadius.mdBorder, - border: Border.all(color: color.withValues(alpha: 0.16)), - ), - child: Icon(icon, size: iconSize, color: color), - ); -} - diff --git a/health_app/lib/widgets/app_card.dart b/health_app/lib/widgets/app_card.dart deleted file mode 100644 index 0187701..0000000 --- a/health_app/lib/widgets/app_card.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import '../core/app_colors.dart'; -import '../core/app_theme.dart'; - -class AppCard extends StatelessWidget { - final Widget child; - final EdgeInsetsGeometry? padding; - final EdgeInsetsGeometry? margin; - final VoidCallback? onTap; - final Color? backgroundColor; - final BorderRadius? borderRadius; - final Border? border; - - const AppCard({ - super.key, - required this.child, - this.padding = const EdgeInsets.all(AppTheme.sLg), - this.margin, - this.onTap, - this.backgroundColor, - this.borderRadius, - this.border, - }); - - @override - Widget build(BuildContext context) { - final radius = borderRadius ?? BorderRadius.circular(AppTheme.rLg); - final card = Container( - margin: - margin ?? - const EdgeInsets.symmetric( - horizontal: AppTheme.sLg, - vertical: AppTheme.sSm, - ), - padding: padding, - decoration: BoxDecoration( - gradient: backgroundColor == null ? AppColors.surfaceGradient : null, - color: backgroundColor, - borderRadius: radius, - border: border ?? Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: child, - ); - - if (onTap == null) return card; - return InkWell(onTap: onTap, borderRadius: radius, child: card); - } -} diff --git a/health_app/lib/widgets/app_menu_item.dart b/health_app/lib/widgets/app_menu_item.dart deleted file mode 100644 index 541b04e..0000000 --- a/health_app/lib/widgets/app_menu_item.dart +++ /dev/null @@ -1,95 +0,0 @@ -import 'package:flutter/material.dart'; -import '../core/app_colors.dart'; - -class AppMenuItem extends StatelessWidget { - final IconData icon; - final String title; - final String? subtitle; - final String? trailing; - final VoidCallback? onTap; - - const AppMenuItem({ - super.key, - required this.icon, - required this.title, - this.subtitle, - this.trailing, - this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Container( - margin: const EdgeInsets.symmetric(horizontal: 20, vertical: 5), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( - children: [ - Container( - width: 40, - height: 40, - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [AppColors.primaryLight, AppColors.infoLight], - ), - borderRadius: BorderRadius.circular(13), - ), - child: Icon(icon, size: 20, color: AppColors.primaryDark), - ), - const SizedBox(width: 14), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w700, - color: AppColors.textPrimary, - ), - ), - if (subtitle != null && subtitle!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - subtitle!, - style: const TextStyle( - fontSize: 13, - color: AppColors.textSecondary, - ), - ), - ), - ], - ), - ), - if (trailing != null && trailing!.isNotEmpty) - Text( - trailing!, - style: const TextStyle( - fontSize: 14, - color: AppColors.textSecondary, - ), - ), - const SizedBox(width: 4), - const Icon( - Icons.chevron_right, - size: 20, - color: AppColors.textHint, - ), - ], - ), - ), - ); - } -} diff --git a/health_app/lib/widgets/app_status_badge.dart b/health_app/lib/widgets/app_status_badge.dart index 723d3e4..183621d 100644 --- a/health_app/lib/widgets/app_status_badge.dart +++ b/health_app/lib/widgets/app_status_badge.dart @@ -1,6 +1,5 @@ import 'package:flutter/material.dart'; -import '../core/app_colors.dart'; import '../core/app_design_tokens.dart'; class AppStatusBadge extends StatelessWidget { @@ -38,13 +37,3 @@ class AppStatusBadge extends StatelessWidget { ), ); } - -class AppStatusColors { - AppStatusColors._(); - - static Color done = AppColors.success; - static Color warning = AppColors.warning; - static Color danger = AppColors.error; - static Color info = AppColors.health; -} - diff --git a/health_app/lib/widgets/app_tab_chip.dart b/health_app/lib/widgets/app_tab_chip.dart deleted file mode 100644 index f07266d..0000000 --- a/health_app/lib/widgets/app_tab_chip.dart +++ /dev/null @@ -1,49 +0,0 @@ -import 'package:flutter/material.dart'; -import '../core/app_colors.dart'; -import '../core/app_theme.dart'; - -class AppTabChip extends StatelessWidget { - final String label; - final bool selected; - final VoidCallback onTap; - - const AppTabChip({ - super.key, - required this.label, - required this.selected, - required this.onTap, - }); - - @override - Widget build(BuildContext context) { - return GestureDetector( - onTap: onTap, - child: AnimatedContainer( - duration: const Duration(milliseconds: 180), - margin: const EdgeInsets.only(right: AppTheme.sSm), - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 9), - decoration: BoxDecoration( - gradient: selected - ? AppColors.primaryGradient - : AppColors.surfaceGradient, - color: selected ? null : Colors.white, - borderRadius: BorderRadius.circular(AppTheme.rPill), - border: Border.all( - color: selected ? AppColors.primaryLight : AppColors.borderLight, - ), - boxShadow: selected - ? AppColors.buttonShadow - : AppColors.cardShadowLight, - ), - child: Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w700, - color: selected ? Colors.white : AppColors.textSecondary, - ), - ), - ), - ); - } -} diff --git a/health_app/lib/widgets/common_widgets.dart b/health_app/lib/widgets/common_widgets.dart index 5918f79..93ccef0 100644 --- a/health_app/lib/widgets/common_widgets.dart +++ b/health_app/lib/widgets/common_widgets.dart @@ -1,117 +1,13 @@ import 'package:flutter/material.dart'; + import '../core/app_colors.dart'; -/// 渐变边框按钮(白色底+彩色渐变边框) -class GradientBorderButton extends StatelessWidget { - final String text; - final IconData? icon; - final VoidCallback? onTap; - final double height; - final bool isSuccess; - - const GradientBorderButton({ - super.key, - required this.text, - this.icon, - this.onTap, - this.height = 52, - this.isSuccess = false, - }); - - @override - Widget build(BuildContext context) { - return Container( - height: height, - decoration: BoxDecoration( - gradient: isSuccess - ? AppColors.calmHealthGradient - : AppColors.primaryGradient, - borderRadius: BorderRadius.circular(14), - boxShadow: AppColors.buttonShadow, - ), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (icon != null) ...[ - Icon(icon, size: 22, color: AppColors.textOnGradient), - const SizedBox(width: 8), - ], - Text( - text, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w600, - color: AppColors.textOnGradient, - ), - ), - ], - ), - ), - ), - ); - } -} - -/// 卡片内的小操作按钮 -class CardActionButton extends StatelessWidget { - final String label; - final IconData icon; - final VoidCallback? onTap; - final Color? iconColor; - - const CardActionButton({ - super.key, - required this.label, - required this.icon, - this.onTap, - this.iconColor, - }); - - @override - Widget build(BuildContext context) { - return InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - gradient: AppColors.surfaceGradient, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppColors.borderLight), - boxShadow: AppColors.cardShadowLight, - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(icon, size: 18, color: iconColor ?? AppColors.primary), - const SizedBox(width: 6), - Text( - label, - style: TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: AppColors.textPrimary, - ), - ), - ], - ), - ), - ); - } -} - -/// 滑动删除组件:左滑露出红色删除背景,达30%阈值锁定,点击确认删除 -/// [margin] 控制卡片之间的间距,red背景与child完全对齐 class SwipeDeleteTile extends StatefulWidget { final Widget child; final VoidCallback onDelete; final VoidCallback? onTap; final EdgeInsetsGeometry margin; + const SwipeDeleteTile({ super.key, required this.child, @@ -119,6 +15,7 @@ class SwipeDeleteTile extends StatefulWidget { this.onTap, this.margin = const EdgeInsets.symmetric(horizontal: 16, vertical: 4), }); + @override State createState() => _SwipeDeleteTileState(); } @@ -127,18 +24,14 @@ class _SwipeDeleteTileState extends State with SingleTickerProviderStateMixin { double _dx = 0; static const _maxSlide = 80.0; - static const _threshold = 24.0; // 30% of 80 + static const _threshold = 24.0; void _onDragUpdate(DragUpdateDetails d) { setState(() => _dx = (_dx + d.delta.dx).clamp(-_maxSlide, 0.0)); } void _onDragEnd(DragEndDetails d) { - if (_dx < -_threshold) { - setState(() => _dx = -_maxSlide); - } else { - setState(() => _dx = 0); - } + setState(() => _dx = _dx < -_threshold ? -_maxSlide : 0); } @override @@ -191,34 +84,3 @@ class _SwipeDeleteTileState extends State ); } } - -/// 功能区小图标容器 -class IconBox extends StatelessWidget { - final IconData icon; - final double size; - final Color? backgroundColor; - final Color? iconColor; - - const IconBox({ - super.key, - required this.icon, - this.size = 44, - this.backgroundColor, - this.iconColor, - }); - - @override - Widget build(BuildContext context) { - return Container( - width: size, - height: size, - decoration: BoxDecoration( - gradient: backgroundColor == null ? AppColors.calmHealthGradient : null, - color: backgroundColor, - borderRadius: BorderRadius.circular(size / 3), - boxShadow: AppColors.cardShadowLight, - ), - child: Icon(icon, size: size * 0.55, color: iconColor ?? Colors.white), - ); - } -} diff --git a/health_app/lib/widgets/drawer_shell.dart b/health_app/lib/widgets/drawer_shell.dart index 0fc25a7..7a97db5 100644 --- a/health_app/lib/widgets/drawer_shell.dart +++ b/health_app/lib/widgets/drawer_shell.dart @@ -3,8 +3,14 @@ import 'package:flutter/material.dart'; class DrawerShell extends StatelessWidget { final double widthFactor; final Widget child; + final bool showBackground; - const DrawerShell({super.key, required this.child, this.widthFactor = 0.84}); + const DrawerShell({ + super.key, + required this.child, + this.widthFactor = 0.84, + this.showBackground = true, + }); @override Widget build(BuildContext context) { @@ -16,29 +22,32 @@ class DrawerShell extends StatelessWidget { borderRadius: const BorderRadius.horizontal(right: Radius.circular(28)), child: Stack( children: [ - Positioned.fill( - child: Image.asset( - 'assets/branding/drawer_background_v1.png', - fit: BoxFit.cover, - alignment: Alignment.centerLeft, + if (showBackground) ...[ + Positioned.fill( + child: Image.asset( + 'assets/branding/drawer_background_v1.png', + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ), ), - ), - Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.white.withValues(alpha: 0.12), - Colors.white.withValues(alpha: 0.42), - Colors.white.withValues(alpha: 0.72), - ], - stops: const [0.0, 0.48, 1.0], + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withValues(alpha: 0.12), + Colors.white.withValues(alpha: 0.42), + Colors.white.withValues(alpha: 0.72), + ], + stops: const [0.0, 0.48, 1.0], + ), ), ), ), - ), + ] else + const Positioned.fill(child: ColoredBox(color: Colors.white)), child, ], ), diff --git a/health_app/lib/widgets/enterprise_widgets.dart b/health_app/lib/widgets/enterprise_widgets.dart index 50a7a5a..ee64f32 100644 --- a/health_app/lib/widgets/enterprise_widgets.dart +++ b/health_app/lib/widgets/enterprise_widgets.dart @@ -1,7 +1,7 @@ import 'package:flutter/material.dart'; + import '../core/app_colors.dart'; import '../core/app_design_tokens.dart'; -import '../core/app_theme.dart'; class EnterpriseStat { final String label; @@ -177,97 +177,3 @@ class _HeaderStat extends StatelessWidget { ); } } - -class EnterpriseSectionCard extends StatelessWidget { - final String? title; - final IconData? icon; - final Color color; - final Widget child; - final EdgeInsetsGeometry padding; - final Widget? trailing; - - const EnterpriseSectionCard({ - super.key, - this.title, - this.icon, - required this.color, - required this.child, - this.trailing, - this.padding = const EdgeInsets.all(16), - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: padding, - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: AppColors.border, width: 1.1), - boxShadow: [AppTheme.shadowLight], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - if (title != null) ...[ - Row( - children: [ - if (icon != null) ...[ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: color.withValues(alpha: 0.10), - borderRadius: BorderRadius.circular(10), - ), - child: Icon(icon, size: 19, color: color), - ), - const SizedBox(width: 10), - ], - Expanded( - child: Text( - title!, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 17, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), - ), - ), - ?trailing, - ], - ), - const SizedBox(height: 14), - ], - child, - ], - ), - ); - } -} - -class EnterpriseFab extends StatelessWidget { - final VoidCallback onPressed; - final IconData icon; - final Color color; - - const EnterpriseFab({ - super.key, - required this.onPressed, - required this.icon, - required this.color, - }); - - @override - Widget build(BuildContext context) { - return FloatingActionButton( - onPressed: onPressed, - backgroundColor: color, - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), - child: Icon(icon, size: 26, color: Colors.white), - ); - } -} diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 5d69ccb..4f0c3ab 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -21,31 +21,100 @@ class HealthDrawer extends ConsumerWidget { return DrawerShell( widthFactor: 0.9, - child: SafeArea( - child: CustomScrollView( - physics: const BouncingScrollPhysics(), - slivers: [ - SliverPadding( - padding: const EdgeInsets.fromLTRB(12, 16, 12, 24), - sliver: SliverList.list( + showBackground: false, + child: CustomScrollView( + physics: const BouncingScrollPhysics(), + slivers: [ + SliverToBoxAdapter( + child: _DrawerHero( + user: auth.user, + latestHealth: latestHealth, + ref: ref, + ), + ), + SliverToBoxAdapter( + child: Container( + padding: const EdgeInsets.fromLTRB(14, 10, 14, 18), + decoration: const BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.vertical(top: Radius.circular(28)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - _AccountHeader(user: auth.user, ref: ref), - const SizedBox(height: 18), - _HealthDashboard(latestHealth: latestHealth, ref: ref), - const SizedBox(height: 18), _NavigationSection(ref: ref), - const SizedBox(height: 18), + const SizedBox(height: 14), + const Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), + ), + const SizedBox(height: 14), _HistorySection(ref: ref), ], ), ), - ], - ), + ), + ], ), ); } } +class _DrawerHero extends StatelessWidget { + final dynamic user; + final AsyncValue> latestHealth; + final WidgetRef ref; + + const _DrawerHero({ + required this.user, + required this.latestHealth, + required this.ref, + }); + + @override + Widget build(BuildContext context) { + final topPadding = MediaQuery.paddingOf(context).top; + return Stack( + children: [ + Positioned.fill( + child: Image.asset( + 'assets/branding/drawer_background_v1.png', + fit: BoxFit.cover, + alignment: Alignment.topLeft, + ), + ), + Positioned.fill( + child: DecoratedBox( + decoration: BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.white.withValues(alpha: 0.12), + Colors.white.withValues(alpha: 0.35), + Colors.white.withValues(alpha: 0.82), + ], + stops: const [0.0, 0.58, 1.0], + ), + ), + ), + ), + Padding( + padding: EdgeInsets.fromLTRB(16, topPadding + 16, 16, 12), + child: Column( + children: [ + _AccountHeader(user: user, ref: ref), + const SizedBox(height: 16), + _HealthDashboard(latestHealth: latestHealth, ref: ref), + ], + ), + ), + ], + ); + } +} + class _AccountHeader extends StatelessWidget { final dynamic user; final WidgetRef ref; @@ -360,13 +429,14 @@ class _NavigationSection extends StatelessWidget { return _LightSection( title: '常用功能', child: GridView.builder( + padding: EdgeInsets.zero, itemCount: items.length, shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 2, - mainAxisExtent: 48, - mainAxisSpacing: 8, + crossAxisCount: 4, + mainAxisExtent: 82, + mainAxisSpacing: 7, crossAxisSpacing: 8, ), itemBuilder: (context, index) { @@ -412,38 +482,42 @@ class _NavTile extends StatelessWidget { return InkWell( onTap: onTap, borderRadius: AppRadius.mdBorder, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.54), - borderRadius: AppRadius.mdBorder, - ), - child: Row( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 2), + child: Column( + mainAxisAlignment: MainAxisAlignment.start, children: [ Container( - width: 30, - height: 30, + width: 52, + height: 52, decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topLeft, end: Alignment.bottomRight, colors: _colors, ), - borderRadius: AppRadius.smBorder, + borderRadius: AppRadius.mdBorder, + boxShadow: [ + BoxShadow( + color: _colors.last.withValues(alpha: 0.18), + blurRadius: 10, + offset: const Offset(0, 5), + ), + ], ), - child: Icon(item.icon, color: Colors.white, size: 19), + child: Icon(item.icon, color: Colors.white, size: 26), ), - const SizedBox(width: 8), - Expanded( - child: Text( - _title, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - ), + const SizedBox(height: 6), + Text( + _title, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: AppColors.textPrimary, + height: 1.1, ), ), ], @@ -471,8 +545,8 @@ class _LightSection extends StatelessWidget { child: Text( title, style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w900, + fontSize: 18, + fontWeight: FontWeight.w800, color: AppColors.textPrimary, ), ), @@ -480,7 +554,7 @@ class _LightSection extends StatelessWidget { ], ), ), - const SizedBox(height: 10), + const SizedBox(height: 2), child, ], ); @@ -516,10 +590,6 @@ class _Panel extends StatelessWidget { ], ), borderRadius: AppRadius.xlBorder, - border: Border.all( - color: Colors.white.withValues(alpha: 0.82), - width: 1.2, - ), boxShadow: AppShadows.soft, ), child: Column( @@ -532,7 +602,7 @@ class _Panel extends StatelessWidget { title, style: TextStyle( fontSize: 18, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, color: titleColor, ), ), @@ -636,24 +706,53 @@ class _NavItem { required this.colors, }); } -class _HistorySection extends ConsumerWidget { + +class _HistorySection extends ConsumerStatefulWidget { final WidgetRef ref; const _HistorySection({required this.ref}); static const int _previewCount = 5; @override - Widget build(BuildContext context, WidgetRef _) { + ConsumerState<_HistorySection> createState() => _HistorySectionState(); +} + +class _HistorySectionState extends ConsumerState<_HistorySection> { + String? _selectedId; + bool _selecting = false; + bool _deleting = false; + + void _enterSelect(String id) { + setState(() { + _selecting = true; + _selectedId = id; + }); + } + + void _toggleSelect(String id) { + setState(() { + if (_selectedId == id) { + _selectedId = null; + _selecting = false; + } else { + _selectedId = id; + } + }); + } + + void _exitSelect() { + setState(() { + _selecting = false; + _selectedId = null; + }); + } + + @override + Widget build(BuildContext context) { final async = ref.watch(conversationHistoryProvider); - return _Panel( + return _LightSection( title: '对话记录', - trailing: _HistoryActions(ref: ref, async: async), - backgroundGradient: const LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFFFFFFF), Color(0xFFF6F1FF), Color(0xFFEFF6FF)], - ), child: async.when( loading: () => const Padding( padding: EdgeInsets.symmetric(vertical: 14), @@ -682,204 +781,268 @@ class _HistorySection extends ConsumerWidget { ), ); } - final preview = list.take(_previewCount).toList(); - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - for (var i = 0; i < preview.length; i++) - _DrawerHistoryTile( - item: preview[i], - isLast: i == preview.length - 1, - onTap: () async { - Navigator.of(context).maybePop(); // 关闭侧边栏 - await ref - .read(chatProvider.notifier) - .loadConversation(preview[i].id); - }, - onDelete: () async { - try { - await ref - .read(conversationHistoryProvider.notifier) - .deleteOne(preview[i].id); - } catch (_) { - // 失败时 provider 已回滚状态,UI 自然恢复 - } - }, - ), - if (list.length > _previewCount) ...[ - const SizedBox(height: 8), - Align( - alignment: Alignment.centerRight, - child: InkWell( - onTap: () { - Navigator.of(context).maybePop(); - pushRoute(ref, 'conversationHistory'); - }, - borderRadius: BorderRadius.circular(999), - child: const Padding( - padding: EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Text( - '查看全部', - style: TextStyle( - fontSize: 12, - fontWeight: FontWeight.w800, - color: AppColors.textSecondary, + final preview = list.take(_HistorySection._previewCount).toList(); + return ConstrainedBox( + constraints: const BoxConstraints(minHeight: 48), + child: Stack( + clipBehavior: Clip.none, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (var i = 0; i < preview.length; i++) + _DrawerHistoryTile( + item: preview[i], + isLast: i == preview.length - 1, + selecting: _selecting, + selected: _selectedId == preview[i].id, + onTap: () async { + if (_selecting) { + _toggleSelect(preview[i].id); + return; + } + final error = await ref + .read(chatProvider.notifier) + .loadConversation(preview[i].id); + if (!context.mounted) return; + if (error != null) { + AppToast.show( + context, + error, + type: AppToastType.error, + ); + return; + } + Navigator.of(context).maybePop(); + }, + onLongPress: () => _enterSelect(preview[i].id), + ), + if (list.length > _HistorySection._previewCount) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerRight, + child: InkWell( + onTap: () { + Navigator.of(context).maybePop(); + pushRoute(ref, 'conversationHistory'); + }, + borderRadius: BorderRadius.circular(999), + child: const Padding( + padding: EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: Text( + '查看全部', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: AppColors.textSecondary, + ), + ), + ), ), ), + ], + ], + ), + if (_selecting && _selectedId != null) + Positioned( + top: -46, + right: 2, + child: _HistorySelectionBar( + deleting: _deleting, + onCancel: _exitSelect, + onDelete: () => _deleteSelected(context), ), ), - ), ], - ], + ), ); }, ), ); } + + Future _deleteSelected(BuildContext context) async { + final id = _selectedId; + if (id == null || _deleting) return; + setState(() => _deleting = true); + try { + await ref.read(conversationHistoryProvider.notifier).deleteOne(id); + _exitSelect(); + } catch (_) { + if (context.mounted) { + AppToast.show(context, '删除失败,请稍后重试', type: AppToastType.error); + } + } finally { + if (mounted) setState(() => _deleting = false); + } + } } -class _HistoryActions extends StatelessWidget { - final WidgetRef ref; - final AsyncValue> async; - const _HistoryActions({required this.ref, required this.async}); +class _HistorySelectionBar extends StatelessWidget { + final bool deleting; + final VoidCallback onCancel; + final VoidCallback onDelete; + + const _HistorySelectionBar({ + required this.deleting, + required this.onCancel, + required this.onDelete, + }); @override Widget build(BuildContext context) { - final hasItems = (async.asData?.value.isNotEmpty ?? false); - return Row( - mainAxisSize: MainAxisSize.min, - children: [ - InkWell( - onTap: () => ref.read(conversationHistoryProvider.notifier).refresh(), - borderRadius: BorderRadius.circular(999), - child: const Padding( - padding: EdgeInsets.all(6), - child: Icon( - Icons.refresh_rounded, - size: 18, - color: Color(0xFF6D28D9), - ), - ), + return Align( + alignment: Alignment.centerRight, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(18), + border: Border.all(color: const Color(0xFFE5E7EB)), + boxShadow: AppColors.cardShadowLight, ), - if (hasItems) - InkWell( - onTap: () => _confirmClearAll(context, ref), - borderRadius: BorderRadius.circular(999), - child: const Padding( - padding: EdgeInsets.all(6), - child: Icon( - Icons.delete_sweep_rounded, - size: 18, - color: AppColors.error, - ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + _HistoryActionButton( + icon: Icons.delete_outline_rounded, + label: deleting ? '删除中' : '删除', + color: AppColors.error, + onTap: deleting ? null : onDelete, ), - ), - ], - ); - } - - Future _confirmClearAll(BuildContext context, WidgetRef ref) async { - final ok = await showDialog( - context: context, - builder: (ctx) => AlertDialog( - title: const Text('清空全部历史'), - content: const Text('清空后无法恢复,确认继续?'), - actions: [ - TextButton( - onPressed: () => Navigator.pop(ctx, false), - child: const Text('取消'), - ), - TextButton( - onPressed: () => Navigator.pop(ctx, true), - style: TextButton.styleFrom(foregroundColor: AppColors.error), - child: const Text('清空'), - ), - ], + const SizedBox(width: 8), + Container(width: 1, height: 22, color: const Color(0xFFE5E7EB)), + const SizedBox(width: 8), + _HistoryActionButton( + icon: Icons.close_rounded, + label: '取消', + color: AppColors.textSecondary, + onTap: onCancel, + ), + ], + ), + ), + ); + } +} + +class _HistoryActionButton extends StatelessWidget { + final IconData icon; + final String label; + final Color color; + final VoidCallback? onTap; + + const _HistoryActionButton({ + required this.icon, + required this.label, + required this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + icon, + size: 18, + color: onTap == null ? AppColors.textHint : color, + ), + const SizedBox(width: 5), + Text( + label, + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w800, + color: onTap == null ? AppColors.textHint : color, + ), + ), + ], + ), ), ); - if (ok != true) return; - try { - await ref.read(conversationHistoryProvider.notifier).clearAll(); - } catch (_) { - if (context.mounted) { - AppToast.show( - context, - '清空失败,请稍后重试', - type: AppToastType.error, - ); - } - } } } class _DrawerHistoryTile extends StatelessWidget { final ConversationListItem item; final bool isLast; + final bool selecting; + final bool selected; final VoidCallback onTap; - final VoidCallback onDelete; + final VoidCallback onLongPress; const _DrawerHistoryTile({ required this.item, required this.isLast, + required this.selecting, + required this.selected, required this.onTap, - required this.onDelete, + required this.onLongPress, }); @override Widget build(BuildContext context) { - return Dismissible( - key: ValueKey('drawer-${item.id}'), - direction: DismissDirection.endToStart, - background: Container( - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 14), - margin: EdgeInsets.only(bottom: isLast ? 0 : 8), - decoration: BoxDecoration( - color: AppColors.error, - borderRadius: BorderRadius.circular(16), - ), - child: const Icon(Icons.delete_outline, color: Colors.white), - ), - confirmDismiss: (_) async { - onDelete(); - return true; - }, - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), - child: Container( - margin: EdgeInsets.only(bottom: isLast ? 0 : 6), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.72), - borderRadius: BorderRadius.circular(14), - border: Border.all(color: Colors.white.withValues(alpha: 0.9)), - ), - child: Row( - children: [ - Expanded( - child: Text( - _displaySummary(item), - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w800, - color: AppColors.textPrimary, - height: 1.2, + return InkWell( + onTap: onTap, + onLongPress: onLongPress, + borderRadius: BorderRadius.circular(8), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 2), + decoration: selected + ? BoxDecoration( + color: const Color(0xFFF1F3F7), + borderRadius: BorderRadius.circular(8), + ) + : null, + child: Column( + children: [ + Padding( + padding: const EdgeInsets.symmetric(vertical: 11), + child: Row( + children: [ + Expanded( + child: Text( + _displaySummary(item), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + fontSize: 15, + fontWeight: FontWeight.w800, + color: selected + ? AppColors.textSecondary + : AppColors.textPrimary, + height: 1.2, + ), + ), ), - ), + const SizedBox(width: 10), + Text( + _shortDate(item.updatedAt), + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: AppColors.textHint, + ), + ), + ], ), - const SizedBox(width: 10), - Text( - _shortDate(item.updatedAt), - style: const TextStyle( - fontSize: 13, - fontWeight: FontWeight.w700, - color: AppColors.textHint, - ), + ), + if (!isLast) + const Divider( + height: 1, + thickness: 0.7, + color: Color(0xFFE8ECF2), ), - ], - ), + ], ), ), ); diff --git a/health_app/test/chat_provider_test.dart b/health_app/test/chat_provider_test.dart new file mode 100644 index 0000000..0f61a59 --- /dev/null +++ b/health_app/test/chat_provider_test.dart @@ -0,0 +1,59 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:health_app/providers/chat_provider.dart'; + +void main() { + test('delayed agent welcome is skipped after session reset', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(chatProvider.notifier); + notifier.triggerAgent(ActiveAgent.diet, '营养助手'); + await notifier.resetSession(); + + await Future.delayed(const Duration(milliseconds: 450)); + + final messages = container.read(chatProvider).messages; + expect(messages, isEmpty); + }); + + test('agent capsule inserts welcome when session is unchanged', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + container + .read(chatProvider.notifier) + .triggerAgent(ActiveAgent.diet, '营养助手'); + + await Future.delayed(const Duration(milliseconds: 450)); + + final messages = container.read(chatProvider).messages; + expect(messages.map((m) => m.type), contains(MessageType.agentWelcome)); + expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); + }); + + test( + 'pending agent capsule ignores later taps until welcome appears', + () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(chatProvider.notifier); + notifier.triggerAgent(ActiveAgent.diet, '营养助手'); + await Future.delayed(const Duration(milliseconds: 120)); + notifier.triggerAgent(ActiveAgent.medication, '药管家'); + + await Future.delayed(const Duration(milliseconds: 450)); + + final welcomeAgents = container + .read(chatProvider) + .messages + .where((m) => m.type == MessageType.agentWelcome) + .map((m) => m.metadata?['agent']) + .toList(); + + expect(welcomeAgents, ['diet']); + expect(container.read(chatProvider).activeAgent, ActiveAgent.diet); + }, + ); +} diff --git a/health_app/test/prelaunch_guardrails_test.dart b/health_app/test/prelaunch_guardrails_test.dart new file mode 100644 index 0000000..85a7a40 --- /dev/null +++ b/health_app/test/prelaunch_guardrails_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:health_app/models/ble_device.dart'; +import 'package:health_app/pages/chart/trend_page.dart'; +import 'package:health_app/pages/remaining_pages.dart'; +import 'package:health_app/services/health_ble_service.dart'; + +void main() { + test('unknown trend metric falls back to blood pressure', () { + expect(normalizeTrendMetricType('unknown_metric'), 'blood_pressure'); + expect(normalizeTrendMetricType(null), 'blood_pressure'); + expect(normalizeTrendMetricType('spo2'), 'spo2'); + }); + + test('static compliance pages include collection and sdk lists', () { + expect(staticTextTitle('personalInfoList'), '个人信息收集清单'); + expect(staticTextContent('personalInfoList'), contains('健康数据')); + + expect(staticTextTitle('thirdPartySdkList'), '第三方 SDK 共享清单'); + expect(staticTextContent('thirdPartySdkList'), contains('AI')); + }); + + test('ble sync is only implemented for blood pressure devices', () { + expect( + HealthBleService.isSyncImplemented(BleDeviceType.bloodPressure), + true, + ); + expect(HealthBleService.isSyncImplemented(BleDeviceType.glucose), false); + expect( + HealthBleService.isSyncImplemented(BleDeviceType.weightScale), + false, + ); + expect( + HealthBleService.isSyncImplemented(BleDeviceType.pulseOximeter), + false, + ); + }); +}