diff --git a/backend/src/Health.Infrastructure/AI/prompt_manager.cs b/backend/src/Health.Infrastructure/AI/prompt_manager.cs index cb160e0..c18cd2c 100644 --- a/backend/src/Health.Infrastructure/AI/prompt_manager.cs +++ b/backend/src/Health.Infrastructure/AI/prompt_manager.cs @@ -135,8 +135,9 @@ public sealed class PromptManager 运动计划参数格式(manage_exercise): - action="create", week_start_date="今天日期(YYYY-MM-DD)" - items: [{"day_of_week":0-6(周日=0),"exercise_type":"散步","duration_minutes":30,"is_rest_day":false}, ...] - - 未说持续多久则默认创建7天 - - 用户说"坚持10天"则创建10天,说"到6月20日"则算天数 + - 时长必须识别中文数字:半小时=30 一小时=60 三十分钟=30 一个半小时=90 一小时二十分钟=80,中文数字必须转为阿拉伯数字 + - 天数:"一个月"=30天 "一周"=7天 "10天"=10天,必须识别中文,默认7天 + - 用户说"坚持X天/月/周"则创建对应天数 - items 数量必须等于持续天数 用药管理参数格式(manage_medication): diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index e6a59a4..9c5d485 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -1,5 +1,6 @@ using System.Drawing; using System.Drawing.Imaging; +using System.Text.Json; using Health.Infrastructure.AI; using Health.Infrastructure.AI.AgentHandlers; @@ -402,6 +403,22 @@ public static class AiChatEndpoints _ => "—" }; + // ── JSON 类型安全转换 ── + private static int _ToInt(object? v) => v switch + { + JsonElement je => je.ValueKind == JsonValueKind.Number ? je.GetInt32() : (int.TryParse(je.GetRawText().Trim('"'), out var i) ? i : 0), + int i => i, + long l => (int)l, + string s => int.TryParse(s, out var si) ? si : 0, + _ => 0 + }; + private static bool _ToBool(object? v) => v switch + { + JsonElement je => je.ValueKind == JsonValueKind.True || je.ValueKind == JsonValueKind.False ? je.GetBoolean() : false, + bool b => b, + _ => false + }; + // ── 消息类型判断 ── private static void _UpdateMessageTypeAndMetadata(string toolName, object toolResult, ref string messageType, ref Dictionary metadata) @@ -455,15 +472,14 @@ public static class AiChatEndpoints if (resultDict != null) { metadata["type"] = "exercise"; - metadata["value"] = ""; - if (resultDict.TryGetValue("exercise_type", out var et)) - metadata["value"] = et.ToString(); - if (resultDict.TryGetValue("duration_minutes", out var dm) && dm is int mins) - metadata["unit"] = $"每天{mins}分钟"; - if (resultDict.TryGetValue("day_count", out var dc) && dc is int days) - metadata["durationDays"] = days; - if (resultDict.TryGetValue("success", out var es) && es is bool eb && eb) - metadata["success"] = true; + resultDict.TryGetValue("exercise_type", out var et); + metadata["value"] = et?.ToString() ?? ""; + resultDict.TryGetValue("duration_minutes", out var dm); + metadata["unit"] = dm != null ? $"每天{dm}分钟" : ""; + resultDict.TryGetValue("day_count", out var dc); + if (dc != null) metadata["durationDays"] = dc.ToString(); + resultDict.TryGetValue("success", out var ok); + metadata["success"] = ok?.ToString() == "True"; metadata["recordTime"] = DateTime.UtcNow.AddHours(8).ToString("MM月dd日 HH:mm"); } break; diff --git a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs index 6731a74..b5b66f8 100644 --- a/backend/src/Health.WebApi/Endpoints/report_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/report_endpoints.cs @@ -16,6 +16,23 @@ public static class ReportEndpoints return Results.Ok(new { code = 0, data = reports, message = (string?)null }); }); + // 删除 + group.MapDelete("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => + { + var userId = GetUserId(http); + var report = await db.Reports.FirstOrDefaultAsync(r => r.Id == id && r.UserId == userId, ct); + if (report == null) return Results.Ok(new { code = 40004, message = "不存在" }); + // 删除本地文件 + if (!string.IsNullOrEmpty(report.FileUrl)) + { + var filePath = Path.Combine(Directory.GetCurrentDirectory(), report.FileUrl.TrimStart('/')); + if (File.Exists(filePath)) File.Delete(filePath); + } + db.Reports.Remove(report); + await db.SaveChangesAsync(ct); + return Results.Ok(new { code = 0, message = "已删除" }); + }); + // 详情 group.MapGet("/{id:guid}", async (Guid id, HttpContext http, AppDbContext db, CancellationToken ct) => { diff --git a/health_app/lib/core/app_colors.dart b/health_app/lib/core/app_colors.dart index d0fefc2..0a5d4a5 100644 --- a/health_app/lib/core/app_colors.dart +++ b/health_app/lib/core/app_colors.dart @@ -138,7 +138,7 @@ class AppColors { static const Color blueGradientEnd = Color(0xFF3B82F6); static LinearGradient get purpleBlueGradient => primaryGradient; static Color get primaryPurple => primary; - static Color get iconColor => primary; + static Color get iconColor => Color(0xFF9B8AF7); // 淡紫图标色 static Color get iconBackground => iconBg; static Color get primaryBlue => blueMeasure; static LinearGradient get lightGradient => gLightPurple; diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index d585f40..4a915d2 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -37,8 +37,6 @@ Widget buildPage(RouteInfo route) { return const MedicationEditPage(); case 'reports': return const ReportListPage(); - case 'reportDetail': - return ReportDetailPage(id: params['id']!); case 'aiAnalysis': return AiAnalysisPage(id: params['id']!); case 'consultation': diff --git a/health_app/lib/core/app_theme.dart b/health_app/lib/core/app_theme.dart index 826a338..0aa0568 100644 --- a/health_app/lib/core/app_theme.dart +++ b/health_app/lib/core/app_theme.dart @@ -2,6 +2,30 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; import 'app_colors.dart'; +/// 渐变背景 Scaffold(紫→蓝 4:6 渐变) +class GradientScaffold extends StatelessWidget { + final PreferredSizeWidget? appBar; + final Widget? body; + final Widget? floatingActionButton; + final Widget? drawer; + final Widget? bottomNavigationBar; + final bool extendBody; + const GradientScaffold({super.key, this.appBar, this.body, this.floatingActionButton, this.drawer, this.bottomNavigationBar, this.extendBody = false}); + + @override Widget build(BuildContext context) => Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: Scaffold( + backgroundColor: Colors.transparent, + appBar: appBar, + body: body, + floatingActionButton: floatingActionButton, + drawer: drawer, + bottomNavigationBar: bottomNavigationBar, + extendBody: extendBody, + ), + ); +} + /// 健康管家 — 设计规范 v1.0:紫色系 + 干净白灰 + 12px圆角 class AppTheme { AppTheme._(); diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 59bc361..68eb698 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -165,8 +165,7 @@ class _TrendPageState extends ConsumerState { } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(title: const Text('健康概览'), centerTitle: true), floatingActionButton: FloatingActionButton( onPressed: _showAddDialog, diff --git a/health_app/lib/pages/consultation/consultation_pages.dart b/health_app/lib/pages/consultation/consultation_pages.dart index 134c922..dbc9f31 100644 --- a/health_app/lib/pages/consultation/consultation_pages.dart +++ b/health_app/lib/pages/consultation/consultation_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 'package:shadcn_ui/shadcn_ui.dart'; +import '../../core/app_colors.dart'; import '../../core/app_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; @@ -63,15 +64,14 @@ class _DoctorChatPageState extends ConsumerState { final state = ref.watch(consultationChatProvider); final canSend = state.status == 'AiTalking' && !state.isSending; - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, title: Column(children: [ Text(state.doctorName.isNotEmpty ? '${state.doctorName} · ${state.doctorDepartment}' : '问诊对话', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), if (state.doctorName.isNotEmpty) - Text(_statusText(state.status), style: const TextStyle(fontSize: 15, color: AppTheme.primaryLight)), + Text(_statusText(state.status), style: const TextStyle(fontSize: 15, color: AppColors.textSecondary)), ]), centerTitle: true, actions: [ @@ -79,11 +79,11 @@ class _DoctorChatPageState extends ConsumerState { margin: const EdgeInsets.only(right: 12), padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( - color: AppTheme.primaryLight, + color: Colors.white, borderRadius: BorderRadius.circular(12), ), child: Text('剩余${state.quotaRemaining}/${state.quotaTotal}次', - style: const TextStyle(fontSize: 15, color: AppTheme.primaryLight, fontWeight: FontWeight.w500)), + style: const TextStyle(fontSize: 15, color: AppColors.textPrimary, fontWeight: FontWeight.w500)), ), ], ), @@ -171,30 +171,47 @@ class _DoctorChatPageState extends ConsumerState { Widget _buildInputBar(bool canSend) { return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + padding: const EdgeInsets.fromLTRB(12, 8, 12, 10), decoration: BoxDecoration( color: Colors.white, border: const Border(top: BorderSide(color: Color(0xFFEEEEEE))), ), - child: Row(children: [ + child: Row(crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( - child: TextField( - controller: _textCtrl, - enabled: canSend, - style: const TextStyle(fontSize: 18), - decoration: InputDecoration( - hintText: canSend ? '描述您的症状...' : '对话已结束', - hintStyle: const TextStyle(fontSize: 18, color: Color(0xFFBBBBBB)), - contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - border: InputBorder.none, + child: Container( + constraints: const BoxConstraints(maxHeight: 120), + decoration: BoxDecoration( + color: AppColors.backgroundSoft, + borderRadius: BorderRadius.circular(20), + ), + child: TextField( + controller: _textCtrl, + enabled: canSend, + maxLines: null, + style: const TextStyle(fontSize: 18), + decoration: InputDecoration( + hintText: canSend ? '描述您的症状...' : '对话已结束', + hintStyle: const TextStyle(fontSize: 18, color: Color(0xFFBBBBBB)), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + border: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + enabledBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + focusedBorder: const OutlineInputBorder(borderRadius: BorderRadius.all(Radius.circular(20)), borderSide: BorderSide.none), + ), + onSubmitted: (_) => _send(), ), - onSubmitted: (_) => _send(), ), ), - IconButton( - icon: Icon(Icons.send, size: 28, - color: canSend ? AppTheme.primary : const Color(0xFFCCCCCC)), - onPressed: canSend ? _send : null, + const SizedBox(width: 8), + GestureDetector( + onTap: canSend ? _send : null, + child: Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: canSend ? AppColors.blueMeasure : const Color(0xFFCCCCCC), + borderRadius: BorderRadius.circular(20), + ), + child: const Icon(Icons.send_rounded, size: 20, color: Colors.white), + ), ), ]), ); diff --git a/health_app/lib/pages/device/device_scan_page.dart b/health_app/lib/pages/device/device_scan_page.dart index cca009f..3d8861d 100644 --- a/health_app/lib/pages/device/device_scan_page.dart +++ b/health_app/lib/pages/device/device_scan_page.dart @@ -4,6 +4,7 @@ import 'package:flutter_blue_plus/flutter_blue_plus.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:permission_handler/permission_handler.dart'; import '../../core/app_colors.dart'; +import '../../core/app_theme.dart'; import '../../providers/auth_provider.dart'; import '../../providers/omron_device_provider.dart'; import '../../services/omron_ble_service.dart'; @@ -96,9 +97,12 @@ class _DeviceScanPageState extends ConsumerState { // ═══════════════════ UI ═══════════════════ @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppColors.background, - appBar: AppBar(backgroundColor: AppColors.cardBackground, title: const Text('添加血压计')), + return GradientScaffold( + appBar: AppBar( + leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => Navigator.of(context).pop()), + backgroundColor: AppColors.cardBackground, + title: const Text('添加血压计'), + ), body: Column(children: [ Container( width: double.infinity, padding: const EdgeInsets.all(16), diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index b9ebe49..bb3f9ab 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -81,14 +81,9 @@ class DietNotifier extends Notifier { try { final api = ref.read(apiClientProvider); final path = state.imagePath!; - debugPrint('[DietDebug] analyzeImage using: $path'); final imageFile = File(path); - final formData = FormData.fromMap({ - 'images': await MultipartFile.fromFile( - imageFile.path, - filename: imageFile.path.split('/').last, - ), + 'images': await MultipartFile.fromFile(imageFile.path, filename: imageFile.path.split('/').last), }); final res = await api.dio.post('/api/ai/analyze-food-image', data: formData); final data = res.data; @@ -96,7 +91,6 @@ class DietNotifier extends Notifier { state = state.copyWith(isAnalyzing: false, errorMessage: data['message'] ?? '识别失败'); return; } - final raw = data['data'] as String? ?? '[]'; final foods = _parseFoodItems(raw); state = state.copyWith(foods: foods, isAnalyzing: false, healthScore: foods.isNotEmpty ? 3 : null); @@ -152,210 +146,137 @@ class DietNotifier extends Notifier { }).toList(); } catch (_) { return [ - FoodItem( - id: 'food_${DateTime.now().millisecondsSinceEpoch}', - name: '识别结果(手动编辑)', - portion: raw.length > 50 ? raw.substring(0, 50) : raw, - calories: 0, - selected: true, - ), + FoodItem(id: 'food_${DateTime.now().millisecondsSinceEpoch}', name: '识别结果(手动编辑)', portion: raw.length > 50 ? raw.substring(0, 50) : raw, calories: 0, selected: true), ]; } } - void updateFoodName(String id, String name) { - final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList(); - state = state.copyWith(foods: foods); - } - - void updateFoodPortion(String id, String portion) { - final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected) : f).toList(); - state = state.copyWith(foods: foods); - } - - void updateFoodCalories(String id, int calories) { - final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList(); - state = state.copyWith(foods: foods); - } - - void toggleFood(String id) { - final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList(); - state = state.copyWith(foods: foods); - } - - void addFood() { - final newId = '${DateTime.now().millisecondsSinceEpoch}'; - final foods = [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)]; - state = state.copyWith(foods: foods); - } - - void removeFood(String id) { - final foods = state.foods.where((f) => f.id != id).toList(); - state = state.copyWith(foods: foods); - } - - void setMealType(String type) { - state = state.copyWith(mealType: type); - } - - void reset() { - state = DietState(); - } + void updateFoodName(String id, String name) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: name, portion: f.portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); } + void updateFoodPortion(String id, String portion) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: portion, calories: f.calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); } + void updateFoodCalories(String id, int calories) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: calories, selected: f.selected) : f).toList(); state = state.copyWith(foods: foods); } + void toggleFood(String id) { final foods = state.foods.map((f) => f.id == id ? FoodItem(id: f.id, name: f.name, portion: f.portion, calories: f.calories, selected: !f.selected) : f).toList(); state = state.copyWith(foods: foods); } + void addFood() { final newId = '${DateTime.now().millisecondsSinceEpoch}'; state = state.copyWith(foods: [...state.foods, FoodItem(id: newId, name: '新食物', portion: '', calories: 100)]); } + void removeFood(String id) { state = state.copyWith(foods: state.foods.where((f) => f.id != id).toList()); } + void setMealType(String type) { state = state.copyWith(mealType: type); } + void reset() { state = DietState(); } Future saveRecord() async { final selectedFoods = state.foods.where((f) => f.selected).toList(); if (selectedFoods.isEmpty) return; - final api = ref.read(apiClientProvider); final mealMap = {'breakfast': 'Breakfast', 'lunch': 'Lunch', 'dinner': 'Dinner', 'snack': 'Snack'}; final totalCal = selectedFoods.fold(0, (s, f) => s + f.calories); - await api.post('/api/diet-records', data: { 'mealType': mealMap[state.mealType] ?? 'Lunch', 'totalCalories': totalCal, 'healthScore': state.healthScore ?? 3, 'recordedAt': DateTime.now().toIso8601String().substring(0, 10), - 'foodItems': selectedFoods.asMap().entries.map((e) => { - 'name': e.value.name, - 'portion': e.value.portion, - 'calories': e.value.calories, - 'sortOrder': e.key, - }).toList(), + 'foodItems': selectedFoods.asMap().entries.map((e) => {'name': e.value.name, 'portion': e.value.portion, 'calories': e.value.calories, 'sortOrder': e.key}).toList(), }); } } +// ─────────── 饮食主题色(暖橙系,不再用紫色)─────────── +const _dietAccent = Color(0xFFF0A060); +const _dietAccentLight = Color(0xFFFFF2E8); +const _dietBg = Color(0xFFFFFBF7); + class DietCapturePage extends ConsumerStatefulWidget { const DietCapturePage({super.key}); @override ConsumerState createState() => _DietCapturePageState(); } class _DietCapturePageState extends ConsumerState { - @override void initState() { - super.initState(); - // 不 reset — 图片由 home_page 传入,这里只做展示 - } + @override void initState() { super.initState(); } @override Widget build(BuildContext context) { final state = ref.watch(dietProvider); - return Scaffold( + backgroundColor: _dietBg, appBar: AppBar( - title: const Text('拍饮食'), + backgroundColor: Colors.white, + title: const Text('饮食分析', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w600)), centerTitle: true, ), body: state.imagePath == null - ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary))) + ? const Center(child: Text('请从首页拍摄或选择食物照片', style: TextStyle(color: AppColors.textSecondary, fontSize: 16))) : _buildResultView(context, ref), ); } - // ─────────── 设计常量(与项目整体色调一致)─────────── - static const _kPrimary = AppTheme.primary; // #6C5CE7 - static const _kPrimaryLight = AppTheme.primaryLight; // #EDEAFF - static const _kPageBg = AppTheme.bg; // #F8F9FC - static const _kSurface = AppTheme.surface; // white - static const _kText = AppTheme.text; // #2D2B32 - static const _kSubText = AppTheme.textSub; // #8A8892 - static const _kBorder = AppTheme.border; // #EAEAF0 - static const _kWarning = AppTheme.warning; // #F5A623 - Widget _buildResultView(BuildContext context, WidgetRef ref) { final state = ref.watch(dietProvider); - final totalCalories = state.foods.where((f) => f.selected).fold(0, (sum, f) => sum + f.calories); + final screenW = MediaQuery.of(context).size.width; return Container( - color: AppColors.background, + decoration: const BoxDecoration(gradient: LinearGradient(colors: [_dietBg, Color(0xFFFFF8F2)])), child: SingleChildScrollView( - padding: const EdgeInsets.all(16), + padding: const EdgeInsets.fromLTRB(16, 12, 16, 24), child: Column(children: [ - _buildImagePreview(state.imagePath!), + // 图片自适应显示 + ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Image.file( + File(state.imagePath!), + width: screenW - 32, + height: 220, + fit: BoxFit.cover, + ), + ), const SizedBox(height: 16), _buildMealSelector(ref), const SizedBox(height: 16), if (state.isAnalyzing) - _buildAnalyzingIndicator(state) + _buildAnalyzing(state) else ...[ if (state.foods.isNotEmpty) ...[ _buildFoodList(ref), const SizedBox(height: 16), - _buildNutritionSummary(totalCalories), + _buildNutritionCard(ref), if (state.commentary != null && state.commentary!.isNotEmpty) ...[ const SizedBox(height: 16), _buildAiCommentary(state.commentary!), ], ], const SizedBox(height: 20), - _buildSubmitButton(), - const SizedBox(height: 16), + _buildSaveButton(), ], ]), ), ); } - // ─────────── 图片预览 ─────────── - Widget _buildImagePreview(String path) { - return Center( - child: ClipRRect( - borderRadius: BorderRadius.circular(20), - child: Container( - height: 200, - width: MediaQuery.of(context).size.width * 0.75, - decoration: BoxDecoration( - image: DecorationImage(image: FileImage(File(path)), fit: BoxFit.cover), - boxShadow: [BoxShadow(color: Colors.black26, blurRadius: 14, offset: const Offset(0, 4))], - ), - foregroundDecoration: const BoxDecoration( - gradient: LinearGradient( - colors: [Colors.transparent, Colors.black38], - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - ), - ), - ), - ), - ); - } - // ─────────── 餐次选择器 ─────────── Widget _buildMealSelector(WidgetRef ref) { final state = ref.watch(dietProvider); final meals = [ - _MealData(Icons.wb_sunny_outlined, '早餐', 'breakfast'), - _MealData(Icons.wb_cloudy_outlined, '午餐', 'lunch'), - _MealData(Icons.nightlight_round, '晚餐', 'dinner'), - _MealData(Icons.local_cafe_outlined, '加餐', 'snack'), + ('🌅', '早餐', 'breakfast'), + ('☀️', '午餐', 'lunch'), + ('🌙', '晚餐', 'dinner'), + ('🍪', '加餐', 'snack'), ]; - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), + return Container( + padding: const EdgeInsets.all(4), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(14)), child: Row( children: meals.map((m) { - final isSelected = state.mealType == m.type; + final sel = state.mealType == m.$3; return Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 4), - child: GestureDetector( - onTap: () => ref.read(dietProvider.notifier).setMealType(m.type), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - padding: const EdgeInsets.symmetric(vertical: 10), - decoration: BoxDecoration( - color: isSelected ? _kPrimary : _kSurface, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: isSelected ? _kPrimary : _kBorder), - boxShadow: isSelected - ? [BoxShadow(color: _kPrimary.withAlpha(30), blurRadius: 6, offset: const Offset(0, 2))] - : null, - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(m.icon, size: 23, color: isSelected ? AppColors.textOnGradient : _kSubText), - const SizedBox(height: 4), - Text(m.label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isSelected ? AppColors.textOnGradient : _kSubText)), - ]), + child: GestureDetector( + onTap: () => ref.read(dietProvider.notifier).setMealType(m.$3), + child: AnimatedContainer( + duration: const Duration(milliseconds: 200), + padding: const EdgeInsets.symmetric(vertical: 12), + decoration: BoxDecoration( + color: sel ? _dietAccentLight : Colors.white, + borderRadius: BorderRadius.circular(12), ), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + Text(m.$1, style: const TextStyle(fontSize: 22)), + const SizedBox(height: 2), + Text(m.$2, style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: sel ? _dietAccent : AppColors.textHint)), + ]), ), ), ); @@ -364,30 +285,19 @@ class _DietCapturePageState extends ConsumerState { ); } - // ─────────── 分析中指示器 ─────────── - Widget _buildAnalyzingIndicator(DietState state) { + // ─────────── 分析中 ─────────── + Widget _buildAnalyzing(DietState state) { return Padding( padding: const EdgeInsets.symmetric(vertical: 40), - child: Center( - child: Column(children: [ - SizedBox( - width: 56, height: 56, - child: Stack( - alignment: Alignment.center, - children: [ - const CircularProgressIndicator(strokeWidth: 3, color: _kPrimary), - const Icon(Icons.restaurant, size: 25, color: _kPrimary), - ], - ), - ), - const SizedBox(height: 20), - const Text('正在识别食物...', style: TextStyle(fontSize: 18, color: _kSubText)), - if (state.errorMessage != null) ...[ - const SizedBox(height: 8), - Text(state.errorMessage!, style: const TextStyle(fontSize: 16, color: AppTheme.error), textAlign: TextAlign.center), - ], - ]), - ), + child: Column(children: [ + const SizedBox(width: 48, height: 48, child: CircularProgressIndicator(strokeWidth: 3, color: _dietAccent)), + const SizedBox(height: 16), + const Text('正在识别食物...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), + if (state.errorMessage != null) ...[ + const SizedBox(height: 8), + Text(state.errorMessage!, style: const TextStyle(fontSize: 14, color: AppColors.error), textAlign: TextAlign.center), + ], + ]), ); } @@ -395,268 +305,183 @@ class _DietCapturePageState extends ConsumerState { Widget _buildFoodList(WidgetRef ref) { final state = ref.watch(dietProvider); final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories); - - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - decoration: BoxDecoration( - color: _kSurface, - borderRadius: BorderRadius.circular(16), - boxShadow: [AppTheme.shadowLight], - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 8, 0), - child: Row(children: [ - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), - decoration: BoxDecoration(color: _kPrimaryLight, borderRadius: BorderRadius.circular(8)), - child: const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kPrimary)), - ), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)), - child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: _kWarning)), - ), - IconButton( - icon: const Icon(Icons.add_circle_outline, size: 25, color: _kPrimary), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => ref.read(dietProvider.notifier).addFood(), - ), - ]), + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: AppColors.cardShadowLight), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: _dietAccent, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const Spacer(), + Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + decoration: BoxDecoration(color: const Color(0xFFFFF3E0), borderRadius: BorderRadius.circular(8)), + child: Text('共 $totalCal kcal', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFFE68A00))), ), - const SizedBox(height: 8), - ...state.foods.map((food) => _buildFoodItem(ref, food)), - const SizedBox(height: 12), ]), - ), + const SizedBox(height: 10), + ...state.foods.map((food) => _foodItemTile(ref, food)), + const SizedBox(height: 4), + Center( + child: TextButton.icon( + onPressed: () => ref.read(dietProvider.notifier).addFood(), + icon: const Icon(Icons.add_circle_outline, size: 20, color: _dietAccent), + label: const Text('添加食物', style: TextStyle(fontSize: 15, color: _dietAccent)), + ), + ), + ]), ); } - Widget _buildFoodItem(WidgetRef ref, FoodItem food) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), - child: AnimatedContainer( - duration: const Duration(milliseconds: 200), - decoration: BoxDecoration( - color: food.selected ? AppColors.iconBg : AppColors.background, - borderRadius: BorderRadius.circular(AppTheme.rMd), - border: Border.all(color: food.selected ? _kPrimary.withAlpha(30) : AppColors.border), + Widget _foodItemTile(WidgetRef ref, FoodItem food) { + return Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.background, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: food.selected ? _dietAccent.withAlpha(60) : AppColors.borderLight), + ), + child: Row(children: [ + GestureDetector( + onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), + child: Container( + width: 22, height: 22, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: food.selected ? _dietAccent : Colors.white, + border: Border.all(color: food.selected ? _dietAccent : AppColors.border, width: 2), + ), + child: food.selected ? const Icon(Icons.check, size: 14, color: Colors.white) : null, + ), ), - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), - child: Row(children: [ - GestureDetector( - onTap: () => ref.read(dietProvider.notifier).toggleFood(food.id), - child: Container( - width: 22, height: 22, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: food.selected ? _kPrimary : AppTheme.surface, - border: Border.all(color: food.selected ? _kPrimary : AppColors.border, width: 2), - ), - child: food.selected ? const Icon(Icons.check, size: 17, color: AppColors.textOnGradient) : null, - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - TextField( - controller: TextEditingController(text: food.name), - onChanged: (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: _kText), - decoration: const InputDecoration( - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - ), - ), - const SizedBox(height: 2), - Row(children: [ - Expanded( - flex: 3, - child: TextField( - controller: TextEditingController(text: food.portion), - onChanged: (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), - style: const TextStyle(fontSize: 15, color: _kSubText), - decoration: const InputDecoration( - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - hintText: '份量', - hintStyle: TextStyle(fontSize: 15, color: AppTheme.textHint), - ), - ), - ), - const SizedBox(width: 8), - Expanded( - flex: 2, - child: TextField( - controller: TextEditingController(text: food.calories.toString()), - onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), - keyboardType: TextInputType.number, - style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: _kWarning), - textAlign: TextAlign.right, - decoration: const InputDecoration( - isDense: true, - contentPadding: EdgeInsets.zero, - border: InputBorder.none, - hintText: '0', - hintStyle: TextStyle(fontSize: 15), - ), - ), - ), - const SizedBox(width: 2), - const Text('kcal', style: TextStyle(fontSize: 14, color: _kSubText)), - ]), - ]), - ), - IconButton( - icon: const Icon(Icons.close, size: 19, color: AppColors.textHint), - padding: EdgeInsets.zero, - constraints: const BoxConstraints(), - onPressed: () => ref.read(dietProvider.notifier).removeFood(food.id), - ), + const SizedBox(width: 10), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + _compactField(food.name, (v) => ref.read(dietProvider.notifier).updateFoodName(food.id, v), style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + const SizedBox(height: 4), + Row(children: [ + Expanded(flex: 3, child: _compactField(food.portion, (v) => ref.read(dietProvider.notifier).updateFoodPortion(food.id, v), hint: '份量', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary))), + const SizedBox(width: 8), + Expanded(flex: 2, child: Row(children: [ + Expanded(child: _compactField(food.calories.toString(), (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0), hint: '0', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFFE68A00)), align: TextAlign.right, keyboardType: TextInputType.number)), + const SizedBox(width: 2), + const Text('kcal', style: TextStyle(fontSize: 13, color: AppColors.textHint)), + ])), + ]), ]), ), - ), + GestureDetector( + onTap: () => ref.read(dietProvider.notifier).removeFood(food.id), + child: const Icon(Icons.close, size: 18, color: AppColors.textHint), + ), + ]), + ); + } + + Widget _compactField(String value, ValueChanged cb, {String? hint, TextStyle? style, TextAlign align = TextAlign.start, TextInputType? keyboardType}) { + return TextField( + controller: TextEditingController(text: value), + onChanged: cb, + keyboardType: keyboardType, + textAlign: align, + style: style ?? const TextStyle(fontSize: 15), + decoration: InputDecoration(isDense: true, contentPadding: EdgeInsets.zero, border: InputBorder.none, hintText: hint, hintStyle: const TextStyle(fontSize: 14, color: AppColors.textHint)), ); } // ─────────── 营养摘要 ─────────── - Widget _buildNutritionSummary(int totalCalories) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(20), - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: _kPrimary.withAlpha(40), blurRadius: 10, offset: const Offset(0, 4))], - ), - child: Row(children: [ - SizedBox( - width: 56, height: 56, - child: Stack( - alignment: Alignment.center, - children: [ - SizedBox( - width: 56, height: 56, - child: CircularProgressIndicator( - value: (totalCalories / 700).clamp(0.0, 1.0), - strokeWidth: 4, - backgroundColor: Colors.white24, - color: AppColors.textOnGradient, - ), - ), - Column(mainAxisSize: MainAxisSize.min, children: [ - Text('$totalCalories', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w800, color: AppColors.textOnGradient)), - Text('kcal', style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)), - ]), - ], - ), - ), - const SizedBox(width: 16), - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text('本餐热量', style: TextStyle(fontSize: 17, color: AppColors.textOnGradient)), - const SizedBox(height: 4), - Row(children: [ - Expanded(child: _macroBar('碳水', 0.55, AppColors.warningLight)), - const SizedBox(width: 8), - Expanded(child: _macroBar('蛋白', 0.25, AppColors.iconBg)), - const SizedBox(width: 8), - Expanded(child: _macroBar('脂肪', 0.20, AppColors.errorLight)), - ]), - ]), - ), - ]), + Widget _buildNutritionCard(WidgetRef ref) { + final state = ref.watch(dietProvider); + final totalCal = state.foods.where((f) => f.selected).fold(0, (s, f) => s + f.calories); + return Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: const Color(0xFFFFF8F0), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFFE8D0)), ), + child: Row(children: [ + SizedBox( + width: 56, height: 56, + child: Stack(alignment: Alignment.center, children: [ + SizedBox(width: 56, height: 56, child: CircularProgressIndicator(value: (totalCal / 700).clamp(0.0, 1.0), strokeWidth: 4, backgroundColor: const Color(0xFFFFE8D0), color: _dietAccent)), + Column(mainAxisSize: MainAxisSize.min, children: [ + Text('$totalCal', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800, color: _dietAccent)), + const Text('kcal', style: TextStyle(fontSize: 12, color: AppColors.textSecondary)), + ]), + ]), + ), + const SizedBox(width: 16), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Text('本餐热量', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 6), + Row(children: [ + _macro('碳水', 0.55, const Color(0xFFF5A623)), + const SizedBox(width: 8), + _macro('蛋白', 0.25, const Color(0xFF4A90D9)), + const SizedBox(width: 8), + _macro('脂肪', 0.20, const Color(0xFFE8686A)), + ]), + ]), + ), + ]), ); } - Widget _macroBar(String label, double ratio, Color color) { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 4), - Text(label, style: const TextStyle(fontSize: 13, color: AppColors.textOnGradient)), + Widget _macro(String label, double ratio, Color color) { + return Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [Container(width: 6, height: 6, decoration: BoxDecoration(color: color, shape: BoxShape.circle)), const SizedBox(width: 4), Text(label, style: const TextStyle(fontSize: 12, color: AppColors.textSecondary))]), + const SizedBox(height: 3), + ClipRRect(borderRadius: BorderRadius.circular(2), child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: AppColors.borderLight, color: color)), ]), - const SizedBox(height: 3), - ClipRRect( - borderRadius: BorderRadius.circular(2), - child: LinearProgressIndicator(value: ratio, minHeight: 4, backgroundColor: Colors.white24, color: color), - ), - ]); + ); } // ─────────── AI 点评 ─────────── Widget _buildAiCommentary(String text) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - gradient: AppColors.gLightPurple, - borderRadius: BorderRadius.circular(16), - boxShadow: [BoxShadow(color: AppTheme.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], - ), - child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - gradient: AppColors.primaryGradient, - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: const Icon(Icons.auto_awesome, size: 20, color: AppColors.textOnGradient), - ), - const SizedBox(width: 12), - Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: _kText, height: 1.6))), - ]), + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBF5), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFFE8C0)), ), + child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Container( + width: 36, height: 36, + decoration: BoxDecoration(color: _dietAccentLight, borderRadius: BorderRadius.circular(10)), + child: const Icon(Icons.auto_awesome, size: 20, color: _dietAccent), + ), + const SizedBox(width: 12), + Expanded(child: Text(text, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.6))), + ]), ); } // ─────────── 保存按钮 ─────────── - Widget _buildSubmitButton() { + Widget _buildSaveButton() { return SizedBox( width: double.infinity, height: 52, - child: Container( - decoration: BoxDecoration( - gradient: AppColors.gPurplePink, - borderRadius: BorderRadius.circular(14), - boxShadow: [BoxShadow(color: AppColors.primary.withAlpha(20), blurRadius: 12, offset: const Offset(0, 4))], - ), - padding: const EdgeInsets.all(1.5), - child: Material( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(12.5), - child: InkWell( - onTap: () async { - try { - await ref.read(dietProvider.notifier).saveRecord(); - if (mounted) popRoute(ref); - } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } - }, - borderRadius: BorderRadius.circular(12), - child: const Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(Icons.check_circle_outline, size: 22, color: AppColors.primary), - SizedBox(width: 8), - Text('保存记录', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.primary)), - ]), - ), + child: ElevatedButton.icon( + onPressed: () async { + try { + await ref.read(dietProvider.notifier).saveRecord(); + if (mounted) popRoute(ref); + } catch (e) { debugPrint('[Diet] 保存记录失败: $e'); } + }, + icon: const Icon(Icons.check_circle_outline, size: 22), + label: const Text('保存记录', style: TextStyle(fontSize: 17, fontWeight: FontWeight.w600)), + style: ElevatedButton.styleFrom( + backgroundColor: _dietAccent, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), ), ), ); } } - -class _MealData { - final IconData icon; - final String label; - final String type; - const _MealData(this.icon, this.label, this.type); -} \ No newline at end of file diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index fb7bfd6..7018579 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -61,7 +61,9 @@ class _HomePageState extends ConsumerState { final currentCount = chatState.messages.length; if (currentCount > _lastMsgCount) { WidgetsBinding.instance.addPostFrameCallback((_) { - if (_scrollCtrl.hasClients) _scrollCtrl.jumpTo(0); + if (_scrollCtrl.hasClients) { + _scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent); + } }); } _lastMsgCount = currentCount; @@ -88,10 +90,9 @@ class _HomePageState extends ConsumerState { child: Row(children: [ Builder(builder: (ctx) => GestureDetector( onTap: () => Scaffold.of(ctx).openDrawer(), - child: Container( - width: 38, height: 38, - decoration: BoxDecoration(color: AppColors.iconBg, borderRadius: BorderRadius.circular(10)), - child: const Icon(LucideIcons.menu, size: 20, color: AppColors.primary), + child: const Padding( + padding: EdgeInsets.all(8), + child: Icon(LucideIcons.menu, size: 22, color: AppColors.textPrimary), ), )), const SizedBox(width: 12), @@ -122,7 +123,7 @@ class _HomePageState extends ConsumerState { (ActiveAgent.health, '记数据', LucideIcons.heart), (ActiveAgent.diet, '拍饮食', LucideIcons.utensils), (ActiveAgent.medication, '药管家', LucideIcons.pill), - (ActiveAgent.report, '看报告', LucideIcons.fileText), + (ActiveAgent.report, '报告分析', LucideIcons.fileText), (ActiveAgent.exercise, '运动', LucideIcons.trendingUp), ]; @@ -139,16 +140,16 @@ class _HomePageState extends ConsumerState { return GestureDetector( onTap: () => ref.read(chatProvider.notifier).triggerAgent(agent, label), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(18), boxShadow: AppColors.cardShadowLight, ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 15, color: AppColors.primary), - const SizedBox(width: 4), - Text(label, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: AppColors.primary)), + Icon(icon, size: 17, color: AppColors.textPrimary), + const SizedBox(width: 5), + Text(label, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), ]), ), ); @@ -194,7 +195,7 @@ class _HomePageState extends ConsumerState { onTap: () => _showAttachmentPicker(context), child: const Padding( padding: EdgeInsets.only(bottom: 10), - child: Icon(LucideIcons.paperclip, size: 24, color: AppColors.textSecondary), + child: Icon(LucideIcons.plus, size: 24, color: AppColors.textSecondary), ), ), const SizedBox(width: 8), @@ -231,7 +232,7 @@ class _HomePageState extends ConsumerState { width: 40, height: 40, margin: EdgeInsets.only(bottom: _pickedImagePath != null ? 0 : 4), decoration: BoxDecoration( - color: AppColors.primary, + color: AppColors.blueMeasure, borderRadius: BorderRadius.circular(20), ), child: const Icon(LucideIcons.send, size: 20, color: Colors.white), 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 24aade8..08fc981 100644 --- a/health_app/lib/pages/home/widgets/chat_messages_view.dart +++ b/health_app/lib/pages/home/widgets/chat_messages_view.dart @@ -1,5 +1,6 @@ import 'dart:io'; 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_theme.dart'; @@ -8,6 +9,28 @@ import '../../../providers/auth_provider.dart'; import '../../../providers/chat_provider.dart'; import '../../../providers/data_providers.dart'; +/// 卡片入场动画包装(从下往上滑入 + 淡入) +class _AnimatedCardEntry extends StatefulWidget { + final Widget child; + const _AnimatedCardEntry({required this.child}); + + @override State<_AnimatedCardEntry> createState() => _AnimatedCardEntryState(); +} + +class _AnimatedCardEntryState extends State<_AnimatedCardEntry> with SingleTickerProviderStateMixin { + late final _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 600)); + late final _slide = Tween(begin: const Offset(0, 0.12), end: Offset.zero).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); + late final _fade = Tween(begin: 0.0, end: 1.0).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOut)); + + @override void initState() { super.initState(); _ctrl.forward(); } + @override void dispose() { _ctrl.dispose(); super.dispose(); } + + @override + Widget build(BuildContext context) { + return FadeTransition(opacity: _fade, child: SlideTransition(position: _slide, child: widget.child)); + } +} + /// 对话消息列表 class ChatMessagesView extends ConsumerWidget { final ScrollController scrollCtrl; @@ -44,11 +67,11 @@ class ChatMessagesView extends ConsumerWidget { return ListView.builder( controller: scrollCtrl, - reverse: true, + reverse: false, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), itemCount: messages.length, itemBuilder: (context, index) { - final msg = messages[messages.length - 1 - index]; + final msg = messages[index]; return _buildMessageContent(context, ref, msg, chatState); }, ); @@ -73,26 +96,6 @@ class ChatMessagesView extends ConsumerWidget { } } - // ═══════════════════════════════════════════════════════════ - // 逐字淡入文本(蚂蚁阿福风格) - // ═══════════════════════════════════════════════════════════ - Widget _buildFadeInText(String text, TextStyle style, String stableId, {bool enabled = true}) { - if (!enabled || text.length <= 2) return Text(text, style: style); - - return Wrap( - children: List.generate(text.length, (i) { - return TweenAnimationBuilder( - key: ValueKey('fade_${stableId}_$i'), - tween: Tween(begin: 0.0, end: 1.0), - duration: const Duration(milliseconds: 200), - curve: Curves.easeOut, - builder: (_, value, child) => Opacity(opacity: value, child: child), - child: Text(text[i], style: style), - ); - }), - ); - } - // ═══════════════════════════════════════════════════════════ // 1. AgentWelcomeCard — 智能体欢迎卡片(蚂蚁阿福风格 + 美化版) // ═══════════════════════════════════════════════════════════ @@ -102,15 +105,21 @@ class ChatMessagesView extends ConsumerWidget { final actions = agent.actions; final features = _getAgentFeatures(agent); final screenWidth = MediaQuery.of(context).size.width; + final agentColors = _agentColors(agent); + final iconGradient = LinearGradient( + colors: agentColors.gradient, + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); - return Align( + return _AnimatedCardEntry(child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(28), border: Border.all(color: Color(0xFFE2E8F0), width: 2), boxShadow: AppColors.cardShadow, ), @@ -125,8 +134,8 @@ class ChatMessagesView extends ConsumerWidget { decoration: BoxDecoration( gradient: LinearGradient( colors: [ - AppColors.purpleGradientStart.withAlpha(40), - AppColors.blueGradientEnd.withAlpha(30), + agentColors.border, + agentColors.bg, ], begin: Alignment.topLeft, end: Alignment.bottomRight, @@ -140,11 +149,11 @@ class ChatMessagesView extends ConsumerWidget { width: 64, height: 64, decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, + gradient: iconGradient, borderRadius: BorderRadius.circular(20), boxShadow: [ BoxShadow( - color: AppColors.primaryPurple.withAlpha(30), + color: agentColors.accent.withAlpha(77), blurRadius: 14, offset: const Offset(0, 5), ), @@ -163,11 +172,12 @@ class ChatMessagesView extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white.withAlpha(160), borderRadius: BorderRadius.circular(10), + boxShadow: AppColors.cardShadowLight, ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.auto_awesome, size: 14, color: AppColors.iconColor), + Icon(Icons.auto_awesome, size: 14, color: agentColors.accent), const SizedBox(width: 4), - Text('AI 智能助手', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.iconColor)), + Text('AI 智能助手', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: agentColors.accent)), ]), ), const SizedBox(height: 10), @@ -184,67 +194,43 @@ class ChatMessagesView extends ConsumerWidget { // ── 功能特性区 ── if (features.isNotEmpty) ...[ Padding( - padding: const EdgeInsets.fromLTRB(20, 16, 20, 0), - child: Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: AppColors.backgroundSecondary, - borderRadius: BorderRadius.circular(18), - border: Border.all(color: AppColors.border), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Container( - width: 6, height: 18, - decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, - borderRadius: BorderRadius.circular(3), - ), + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: features.any((f) => f.imageAsset != null) + ? ClipRRect( + borderRadius: BorderRadius.circular(20), + child: Image.asset( + features.firstWhere((f) => f.imageAsset != null).imageAsset!, + width: double.infinity, height: 170, fit: BoxFit.fill, ), - const SizedBox(width: 8), - Text('核心功能', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), - ]), - const SizedBox(height: 14), - Row( - children: features.map((feature) { - return Expanded( - child: Container( - margin: EdgeInsets.only( - left: features.indexOf(feature) == 0 ? 0 : 6, - right: features.indexOf(feature) == features.length - 1 ? 0 : 6, + ) + : Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.border)), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: agentColors.accent, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + const Text('核心功能', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: AppColors.textSecondary)), + ]), + const SizedBox(height: 10), + Row(children: features.map((feature) { + return Expanded( + child: Container( + margin: EdgeInsets.symmetric(horizontal: 3), + padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 6), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Column(children: [ + Container(width: 40, height: 40, decoration: BoxDecoration(color: agentColors.iconBg, borderRadius: BorderRadius.circular(10)), + child: Icon(feature.icon, size: 20, color: agentColors.accent)), + const SizedBox(height: 6), + Text(feature.title, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + Text(feature.desc, style: const TextStyle(fontSize: 11, color: AppColors.textHint)), + ]), ), - padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 8), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: AppColors.borderLight), - ), - child: Column( - children: [ - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - gradient: AppColors.lightGradient, - borderRadius: BorderRadius.circular(14), - ), - child: Icon(feature.icon, size: 24, color: AppColors.iconColor), - ), - const SizedBox(height: 8), - Text(feature.title, style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: AppColors.textPrimary), textAlign: TextAlign.center), - const SizedBox(height: 2), - Text(feature.desc, style: const TextStyle(fontSize: 13, color: AppColors.textHint), textAlign: TextAlign.center), - ], - ), - ), - ); - }).toList(), + ); + }).toList()), + ]), ), - ], - ), - ), ), ], @@ -253,7 +239,7 @@ class ChatMessagesView extends ConsumerWidget { const SizedBox(height: 14), Padding( padding: const EdgeInsets.symmetric(horizontal: 20), - child: _buildDoctorCards(ref), + child: _buildDoctorCards(ref, agentColors), ), ], @@ -269,7 +255,7 @@ class ChatMessagesView extends ConsumerWidget { Container( width: 6, height: 18, decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, + gradient: iconGradient, borderRadius: BorderRadius.circular(3), ), ), @@ -277,10 +263,13 @@ class ChatMessagesView extends ConsumerWidget { Text('快捷操作', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), ]), const SizedBox(height: 12), - Wrap( - spacing: 10, - runSpacing: 10, - children: actions.map((a) => _buildActionButton(a, context, ref)).toList(), + Row( + children: [ + for (int i = 0; i < actions.length; i++) ...[ + if (i > 0) const SizedBox(width: 10), + Expanded(child: _buildActionButton(actions[i], context, ref, agentColors)), + ], + ], ), ], ), @@ -294,24 +283,24 @@ class ChatMessagesView extends ConsumerWidget { child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), decoration: BoxDecoration( - color: AppColors.iconBackground, + color: agentColors.iconBg, borderRadius: BorderRadius.circular(12), ), child: Row(children: [ - Icon(Icons.lightbulb_outline, size: 18, color: AppColors.iconColor), + Icon(Icons.lightbulb_outline, size: 18, color: agentColors.accent), const SizedBox(width: 8), - Expanded(child: Text('直接描述您的需求,AI 会自动为您记录和分析', style: TextStyle(fontSize: 14, color: AppColors.iconColor, fontWeight: FontWeight.w500))), + Expanded(child: Text('直接描述您的需求,AI 会自动为您记录和分析', style: TextStyle(fontSize: 14, color: agentColors.accent, fontWeight: FontWeight.w500))), ]), ), ), ], ), ), + ), ); } - Widget _buildActionButton(_AgentAction a, BuildContext context, WidgetRef ref) { - final screenWidth = MediaQuery.of(context).size.width; + Widget _buildActionButton(_AgentAction a, BuildContext context, WidgetRef ref, _AgentColors colors) { return InkWell( onTap: () { if (a.label == '服药打卡') { @@ -326,13 +315,12 @@ class ChatMessagesView extends ConsumerWidget { } } }, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), child: Container( - width: ((screenWidth - 40 - 10) / 2) - 5, padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.borderLight, width: 1.5), boxShadow: AppColors.cardShadow, ), @@ -341,10 +329,10 @@ class ChatMessagesView extends ConsumerWidget { Container( width: 40, height: 40, decoration: BoxDecoration( - gradient: AppColors.lightGradient, + color: colors.iconBg, borderRadius: BorderRadius.circular(12), ), - child: Icon(a.icon, size: 22, color: AppColors.iconColor), + child: Icon(a.icon, size: 22, color: colors.accent), ), const SizedBox(width: 12), Expanded(child: Text(a.label, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), @@ -362,7 +350,7 @@ class ChatMessagesView extends ConsumerWidget { _AgentFeature(Icons.add, '快速录入', '血压心率血糖'), ], ActiveAgent.diet => [ - _AgentFeature(Icons.camera_alt, '拍照识别', '自动识别食物'), + _AgentFeature(Icons.camera_alt, '拍照识别', '自动识别食物', imageAsset: 'assets/images/diet.png'), _AgentFeature(Icons.bar_chart, '营养分析', '热量营养成分'), ], ActiveAgent.medication => [ @@ -374,11 +362,11 @@ class ChatMessagesView extends ConsumerWidget { _AgentFeature(Icons.local_hospital, '科室选择', '精准匹配'), ], ActiveAgent.report => [ - _AgentFeature(Icons.upload, '上传报告', '支持多种格式'), + _AgentFeature(Icons.upload, '上传报告', '支持多种格式', imageAsset: 'assets/images/Report Analysis.png'), _AgentFeature(Icons.search, 'AI解读', '智能分析报告'), ], ActiveAgent.exercise => [ - _AgentFeature(Icons.calendar_month, '运动计划', '科学规划'), + _AgentFeature(Icons.calendar_month, '运动计划', '科学规划', imageAsset: 'assets/images/sport.png'), _AgentFeature(Icons.check_circle, '打卡记录', '坚持完成'), ], _ => [ @@ -390,7 +378,7 @@ class ChatMessagesView extends ConsumerWidget { - Widget _buildDoctorCards(WidgetRef ref) { + Widget _buildDoctorCards(WidgetRef ref, _AgentColors colors) { const doctors = [ {'name': '张明', 'title': '主任医师', 'dept': '心脏康复科', 'desc': '冠心病、高血压术后管理', 'id': '468b82e2-d95a-4436-bff6-a50eecf99a66'}, {'name': '李芳', 'title': '副主任医师', 'dept': '营养科', 'desc': '糖尿病、甲状腺疾病管理', 'id': 'd4148733-b538-4398-af17-0c7592fc0c2d'}, @@ -400,13 +388,13 @@ class ChatMessagesView extends ConsumerWidget { children: [ for (var i = 0; i < doctors.length; i++) ...[ if (i > 0) const SizedBox(width: 8), - Expanded(child: _doctorCard(doctors[i], ref)), + Expanded(child: _doctorCard(doctors[i], ref, colors)), ], ], ); } - Widget _doctorCard(Map doc, WidgetRef ref) { + Widget _doctorCard(Map doc, WidgetRef ref, _AgentColors colors) { return InkWell( onTap: () => pushRoute(ref, 'consultation', params: {'id': doc['id']!}), borderRadius: BorderRadius.circular(12), @@ -415,13 +403,13 @@ class ChatMessagesView extends ConsumerWidget { decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: AppTheme.primaryLight), + border: Border.all(color: colors.border), ), child: Column(mainAxisSize: MainAxisSize.min, children: [ CircleAvatar( radius: 22, - backgroundColor: AppTheme.primaryLight, - child: Text(doc['name']![0], style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: AppTheme.primary)), + backgroundColor: colors.iconBg, + child: Text(doc['name']![0], style: TextStyle(fontSize: 21, fontWeight: FontWeight.w600, color: colors.accent)), ), const SizedBox(height: 6), Text(doc['name']!, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), @@ -430,10 +418,10 @@ class ChatMessagesView extends ConsumerWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), decoration: BoxDecoration( - color: AppTheme.primaryLight, + color: colors.iconBg, borderRadius: BorderRadius.circular(4), ), - child: Text(doc['dept']!, style: const TextStyle(fontSize: 13, color: AppTheme.primary)), + child: Text(doc['dept']!, style: TextStyle(fontSize: 13, color: colors.accent)), ), const SizedBox(height: 4), Text( @@ -474,7 +462,7 @@ class ChatMessagesView extends ConsumerWidget { List<_ConfirmField> fields = []; if (isMedication) { - // 药品录入 + // 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息 title = '药品录入确认'; titleIcon = Icons.medication_liquid_outlined; mainLabel = meta['name'] as String? ?? ''; @@ -482,59 +470,60 @@ class ChatMessagesView extends ConsumerWidget { mainUnit = ''; if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final nameValue = meta['药品名称'] as String? ?? mainLabel; - fields.add(_ConfirmField(label: '药品名称', value: nameValue, icon: Icons.medication_outlined, editable: true)); - if (mainValue.isNotEmpty) { - final dosageValue = meta['剂量'] as String? ?? mainValue; - fields.add(_ConfirmField(label: '剂量', value: dosageValue, icon: Icons.local_pharmacy_outlined, editable: true)); - } final time = meta['time'] as String? ?? ''; if (time.isNotEmpty) { final timeValue = meta['服药时间'] as String? ?? time; - fields.add(_ConfirmField(label: '服药时间', value: timeValue, icon: Icons.access_time, editable: true)); + fields.add(_ConfirmField(label: '服药时间', value: timeValue, editable: true)); } final frequency = meta['frequency'] as String? ?? ''; if (frequency.isNotEmpty) { final freqValue = meta['频率'] as String? ?? _freqLabel(frequency); - fields.add(_ConfirmField(label: '频率', value: freqValue, icon: Icons.repeat_outlined, editable: true)); + fields.add(_ConfirmField(label: '频率', value: freqValue, editable: true)); } final duration = meta['duration_days'] as int?; if (duration != null && duration > 0) { final durationValue = meta['服用天数'] as String? ?? '$duration 天'; - fields.add(_ConfirmField(label: '服用天数', value: durationValue, icon: Icons.event_available_outlined, editable: true)); + fields.add(_ConfirmField(label: '服用天数', value: durationValue, editable: true)); } } else if (isExercise) { - // 运动计划 + // 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数 title = '运动计划确认'; titleIcon = Icons.directions_run_outlined; - mainLabel = meta['name'] as String? ?? meta['activity'] as String? ?? '运动计划'; - mainValue = meta['duration'] as String? ?? ''; - mainUnit = meta['duration_unit'] as String? ?? '分钟'; + final exName = (meta['value'] ?? meta['name'] ?? meta['exerciseType'] ?? meta['运动类型'] ?? '').toString(); + final exUnitRaw = (meta['unit'] ?? meta['duration_unit'] ?? meta['durationUnit'] ?? '').toString(); + final exDaysRaw = meta['durationDays'] ?? meta['day_count']; + final exDays = int.tryParse(exDaysRaw?.toString() ?? '') ?? 0; + + // 解析 "每天30分钟" → 频率=每天, 时长=30分钟 + final freqMatch = RegExp(r'^(每天|每周|每月)').firstMatch(exUnitRaw); + final durMatch = RegExp(r'(\d+分钟)').firstMatch(exUnitRaw); + final freq = freqMatch?.group(1) ?? ''; + final dur = durMatch?.group(1) ?? exUnitRaw; + + mainLabel = '运动计划'; + mainValue = exName.isNotEmpty ? exName : '运动计划'; + mainUnit = ''; if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final typeValue = meta['运动类型'] as String? ?? mainLabel; - fields.add(_ConfirmField(label: '运动类型', value: typeValue, icon: Icons.fitness_center_outlined, editable: true)); - if (mainValue.isNotEmpty) { - final durationValue = meta['运动时长'] as String? ?? '$mainValue $mainUnit'; - fields.add(_ConfirmField(label: '运动时长', value: durationValue, icon: Icons.timer_outlined, editable: true)); + if (dur.isNotEmpty) { + fields.add(_ConfirmField(label: '运动时长', value: dur, editable: true)); } - final intensity = meta['intensity'] as String? ?? ''; + if (freq.isNotEmpty) { + fields.add(_ConfirmField(label: '频率', value: freq)); + } + if (exDays > 0) { + fields.add(_ConfirmField(label: '计划天数', value: '${exDays}天')); + } + final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString(); if (intensity.isNotEmpty) { - final intensityValue = meta['运动强度'] as String? ?? intensity; - fields.add(_ConfirmField(label: '运动强度', value: intensityValue, icon: Icons.local_fire_department_outlined, editable: true)); + fields.add(_ConfirmField(label: '运动强度', value: intensity, editable: true)); } - final calories = meta['calories'] as String? ?? meta['calorie'] as String? ?? ''; + final calories = (meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '').toString(); if (calories.isNotEmpty) { - final caloriesValue = meta['消耗热量'] as String? ?? '$calories kcal'; - fields.add(_ConfirmField(label: '消耗热量', value: caloriesValue, icon: Icons.local_fire_department, editable: true)); - } - final schedule = meta['schedule'] as String? ?? ''; - if (schedule.isNotEmpty) { - final scheduleValue = meta['计划时间'] as String? ?? schedule; - fields.add(_ConfirmField(label: '计划时间', value: scheduleValue, icon: Icons.calendar_month_outlined, editable: true)); + fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal', editable: true)); } } else { - // 健康指标 + // 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息 title = '健康数据确认'; titleIcon = Icons.monitor_heart_outlined; mainLabel = _getMetricName(backendType); @@ -542,25 +531,23 @@ class ChatMessagesView extends ConsumerWidget { mainUnit = meta['unit'] as String? ?? _getMetricUnit(backendType); if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt); - final metricValue = meta[mainLabel] as String? ?? '$mainValue $mainUnit'; - fields.add(_ConfirmField(label: mainLabel, value: metricValue, icon: Icons.favorite_outline, editable: true)); final timeValue = meta['记录时间'] as String? ?? recordTime; - fields.add(_ConfirmField(label: '记录时间', value: timeValue, icon: Icons.access_time, editable: true)); + fields.add(_ConfirmField(label: '记录时间', value: timeValue, editable: true)); final note = meta['note'] as String? ?? ''; if (note.isNotEmpty) { final noteValue = meta['备注'] as String? ?? note; - fields.add(_ConfirmField(label: '备注', value: noteValue, icon: Icons.edit_note_outlined, editable: true)); + fields.add(_ConfirmField(label: '备注', value: noteValue, editable: true)); } } - return Align( + return _AnimatedCardEntry(child: Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 16), constraints: BoxConstraints(maxWidth: screenWidth * 0.95), decoration: BoxDecoration( color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(28), border: Border.all(color: Color(0xFFE2E8F0), width: 2), boxShadow: AppColors.cardShadow, ), @@ -572,15 +559,8 @@ class ChatMessagesView extends ConsumerWidget { Container( width: double.infinity, padding: const EdgeInsets.fromLTRB(20, 20, 20, 18), - decoration: BoxDecoration( - gradient: LinearGradient( - colors: [ - AppColors.purpleGradientStart.withAlpha(45), - AppColors.blueGradientEnd.withAlpha(25), - ], - begin: Alignment.topLeft, - end: Alignment.bottomRight, - ), + decoration: const BoxDecoration( + color: Color(0xFFF8F6FF), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, @@ -589,7 +569,7 @@ class ChatMessagesView extends ConsumerWidget { width: 56, height: 56, decoration: BoxDecoration( - gradient: AppColors.purpleBlueGradient, + color: AppColors.cardInner, borderRadius: BorderRadius.circular(16), boxShadow: [ BoxShadow(color: AppColors.primaryPurple.withAlpha(35), blurRadius: 10, offset: const Offset(0, 4)), @@ -655,31 +635,33 @@ class ChatMessagesView extends ConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(mainLabel, style: const TextStyle(fontSize: 17, color: AppColors.textSecondary)), - const SizedBox(height: 8), - RichText( - text: TextSpan( - children: [ - TextSpan( - text: mainValue.isNotEmpty ? mainValue : '—', - style: TextStyle( - fontSize: 36, - fontWeight: FontWeight.w800, - color: abnormal ? AppColors.error : AppColors.textPrimary, - letterSpacing: -0.5, - ), - ), - if (mainUnit.isNotEmpty) + if (mainValue.isNotEmpty) ...[ + const SizedBox(height: 8), + RichText( + text: TextSpan( + children: [ TextSpan( - text: ' $mainUnit', + text: mainValue, style: TextStyle( - fontSize: 19, - color: abnormal ? AppColors.error : AppColors.iconColor, - fontWeight: FontWeight.w500, + fontSize: 36, + fontWeight: FontWeight.w800, + color: abnormal ? AppColors.error : AppColors.textPrimary, + letterSpacing: -0.5, ), ), - ], + if (mainUnit.isNotEmpty) + TextSpan( + text: ' $mainUnit', + style: TextStyle( + fontSize: 19, + color: abnormal ? AppColors.error : AppColors.iconColor, + fontWeight: FontWeight.w500, + ), + ), + ], + ), ), - ), + ], ], ), ), @@ -724,7 +706,7 @@ class ChatMessagesView extends ConsumerWidget { child: Container( decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.border), ), child: Column( @@ -748,7 +730,7 @@ class ChatMessagesView extends ConsumerWidget { height: 56, decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), border: Border.all(color: AppColors.borderLight, width: 1.5), boxShadow: AppColors.cardShadow, ), @@ -756,7 +738,7 @@ class ChatMessagesView extends ConsumerWidget { ? Container( decoration: BoxDecoration( gradient: AppColors.successButtonGradient, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), boxShadow: AppColors.buttonShadow, ), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -771,7 +753,7 @@ class ChatMessagesView extends ConsumerWidget { onTap: () { ref.read(chatProvider.notifier).confirmMessage(msg.id); }, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(20), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ @@ -795,6 +777,7 @@ class ChatMessagesView extends ConsumerWidget { ], ), ), + ), ); } @@ -802,40 +785,27 @@ class ChatMessagesView extends ConsumerWidget { final isEditing = field.label == msg.metadata?['_editingField']; return Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row( + crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container( - width: 42, height: 42, - decoration: BoxDecoration(color: AppColors.iconBackground, borderRadius: BorderRadius.circular(12)), - child: Icon(field.icon, size: 22, color: AppColors.iconColor), + SizedBox( + width: 80, + child: Text(field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint)), ), const SizedBox(width: 12), Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(field.label, style: const TextStyle(fontSize: 15, color: AppColors.textHint)), - const SizedBox(height: 4), - if (!field.editable) - Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)) - else if (isEditing) - _buildEditableTextField(field, msg, ref) - else - InkWell( - onTap: () { - ref.read(chatProvider.notifier).startEditingField(msg.id, field.label); - }, - child: Row( - children: [ - Expanded(child: Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), - const SizedBox(width: 8), - Icon(Icons.edit_outlined, size: 18, color: AppColors.border), - ], - ), + child: !field.editable + ? Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary)) + : isEditing + ? _buildEditableTextField(field, msg, ref) + : InkWell( + onTap: () => ref.read(chatProvider.notifier).startEditingField(msg.id, field.label), + child: Row(children: [ + Expanded(child: Text(field.value.isNotEmpty ? field.value : '未设置', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: AppColors.textPrimary))), + Icon(Icons.edit_outlined, size: 18, color: AppColors.border), + ]), ), - ], - ), ), ], ), @@ -846,28 +816,30 @@ class ChatMessagesView extends ConsumerWidget { return StatefulBuilder( builder: (context, setState) { final controller = TextEditingController(text: field.value); - return Container( - decoration: BoxDecoration( - border: Border.all(color: AppColors.primaryBlue.withAlpha(150), width: 1.5), - borderRadius: BorderRadius.circular(8), - color: AppColors.backgroundSecondary, - ), - child: TextField( - controller: controller, - autofocus: true, - style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary), - decoration: const InputDecoration( - contentPadding: EdgeInsets.symmetric(horizontal: 12, vertical: 8), - border: InputBorder.none, + return TextField( + controller: controller, + autofocus: true, + style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary), + decoration: InputDecoration( + contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + filled: true, + fillColor: Colors.white, + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: BorderSide(color: AppColors.primary.withAlpha(80)), ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(8), + borderSide: const BorderSide(color: AppColors.primary, width: 1.5), + ), + ), onSubmitted: (value) { ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, value); }, onTapOutside: (_) { ref.read(chatProvider.notifier).finishEditingField(msg.id, field.label, controller.text); }, - ), - ); + ); }, ); } @@ -941,11 +913,17 @@ class ChatMessagesView extends ConsumerWidget { // 文字内容 if (isUser) SelectableText(msg.content, style: const TextStyle(fontSize: 19, color: Colors.white, height: 1.5)) - else if (chatState?.isStreaming ?? false) - // 流式输出中:逐字淡入 - _buildFadeInText(_stripMd(msg.content), const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5), msg.id) else - SelectableText(_stripMd(msg.content), style: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5)), + MarkdownBody( + data: _cleanAiText(msg.content), + selectable: true, + styleSheet: MarkdownStyleSheet( + p: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5), + strong: const TextStyle(fontSize: 19, color: AppColors.textPrimary, height: 1.5, fontWeight: FontWeight.w700), + h1: const TextStyle(fontSize: 21, color: AppColors.textPrimary, fontWeight: FontWeight.w700), + h2: const TextStyle(fontSize: 20, color: AppColors.textPrimary, fontWeight: FontWeight.w700), + ), + ), // 图片缩略图(在文字下方) if (hasImage) @@ -1051,16 +1029,10 @@ class ChatMessagesView extends ConsumerWidget { ); } - /// 去掉 AI 返回的 Markdown 标记(**加粗**、*斜体*、`代码`、标题、列表符号等) - static String _stripMd(String text) { + /// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染) + static String _cleanAiText(String text) { return text - .replaceAll(RegExp(r'\*\*(.+?)\*\*'), r'$1') - .replaceAll(RegExp(r'\*(.+?)\*'), r'$1') - .replaceAll(RegExp(r'^#{1,6}\s+', multiLine: true), '') - .replaceAll(RegExp(r'^[\-\*]\s+', multiLine: true), '') - .replaceAll(RegExp(r'`(.+?)`'), r'$1') - .replaceAll(RegExp(r'\[([^\]]+)\]\([^)]+\)'), r'$1') - .replaceAll('\$1', '') // AI 偶尔输出 $1 占位符 + .replaceAll('\$1', '') // AI 偶尔输出 $1 占位符(正则残留) .replaceAll(RegExp(r'\n{3,}'), '\n\n') .trim(); } @@ -1181,11 +1153,11 @@ class ChatMessagesView extends ConsumerWidget { accent: const Color(0xFFF0A060), ), ActiveAgent.medication => _AgentColors( - gradient: [const Color(0xFFFFD4E0), const Color(0xFFFFB8CC), const Color(0xFFE898A8)], - bg: const Color(0xFFFFF0F4), - border: const Color(0xFFFFE0E8), - iconBg: const Color(0xFFFFE8EE), - accent: const Color(0xFFE898A8), + gradient: [const Color(0xFFB8E0D8), const Color(0xFF8ED4C8), const Color(0xFF68B8AC)], + bg: const Color(0xFFF0FAF8), + border: const Color(0xFFD4ECE8), + iconBg: const Color(0xFFE4F6F2), + accent: const Color(0xFF68B8AC), ), ActiveAgent.report => _AgentColors( gradient: [const Color(0xFFD8D0F0), const Color(0xFFC4B8EC), const Color(0xFFA898D8)], @@ -1217,7 +1189,7 @@ class ChatMessagesView extends ConsumerWidget { ActiveAgent.diet => (Icons.restaurant, '拍饮食', '拍照识别食物热量和营养成分'), ActiveAgent.medication => (Icons.medication, '药管家', '管理药品、提醒服药、追踪用量'), ActiveAgent.consultation => (Icons.local_hospital, '问诊', '在线咨询医生,描述症状获取建议'), - ActiveAgent.report => (Icons.assignment, '看报告', '上传体检报告,AI 辅助解读'), + ActiveAgent.report => (Icons.assignment, '报告分析', '上传体检报告,AI 辅助解读'), ActiveAgent.exercise => (Icons.directions_run, '运动', '制定运动计划,打卡记录进度'), _ => (Icons.smart_toy, 'AI 助手', '您的智能健康管家'), }; @@ -1249,7 +1221,7 @@ class ChatMessagesView extends ConsumerWidget { final now = DateTime.now(); final tasks = []; - // 1. 健康数据摘要行 + // ── 解析健康指标 ── final bp = healthData['BloodPressure']; final bpText = bp is Map ? '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}' : null; final hr = healthData['HeartRate']; @@ -1260,20 +1232,75 @@ class ChatMessagesView extends ConsumerWidget { final boText = bo is num ? '$bo' : null; final wt = healthData['Weight']; final wtText = wt is num ? '$wt' : null; - final allNull = bpText == null && hrText == null && bsText == null && boText == null && wtText == null; - if (!allNull) { - tasks.add(_taskRow(context, Icons.check_circle, '今日已记录', trailing: [ - if (bpText != null) '血压 $bpText', - if (hrText != null) '心率 $hrText', - if (bsText != null) '血糖 $bsText', - if (boText != null) '血氧 $boText', - if (wtText != null) '体重 $wtText', - ].join(' · '), status: 'done', onTap: () => pushRoute(ref, 'trend'))); + // 异常检测 + final bpAbnormal = bp is Map && bp['systolic'] is int && (bp['systolic'] as int) >= 140; + final hasAbnormal = bpAbnormal; + + // ── 1. 健康指标 ── + const healthIconColor = Color(0xFF3B82F6); + const healthIconBg = Color(0xFFDBEAFE); + if (allNull) { + tasks.add(_taskRow(context, Icons.monitor_heart_outlined, '健康指标', + trailing: '今日暂无记录,点击录入', + status: 'pending', iconColor: healthIconColor, iconBg: healthIconBg, + onTap: () => pushRoute(ref, 'trend'))); + } else { + final parts = []; + if (bpText != null) parts.add('血压 $bpText'); + if (hrText != null) parts.add('心率 $hrText'); + if (bsText != null) parts.add('血糖 $bsText'); + if (boText != null) parts.add('血氧 $boText'); + if (wtText != null) parts.add('体重 $wtText'); + tasks.add(_taskRow(context, Icons.check_circle, '健康指标', + trailing: parts.join(' · '), + status: hasAbnormal ? 'warning' : 'done', + iconColor: healthIconColor, iconBg: healthIconBg, + onTap: () => pushRoute(ref, 'trend'))); + } + if (bpAbnormal) { + final s = bp['systolic']; + final d = bp['diastolic'] ?? '--'; + tasks.add(_taskRow(context, Icons.warning_amber_rounded, '血压 $s/$d 偏高,请关注', + status: 'warning', + onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}))); } - // 2. 用药提醒 — 一行汇总 + // ── 2. 运动 ── + const exIconColor = Color(0xFFF59E0B); + const exIconBg = Color(0xFFFEF3C7); + final exercisePlan = ref.watch(currentExercisePlanProvider); + var hasExercise = false; + exercisePlan.whenOrNull(data: (plan) { + if (plan != null) { + final items = (plan['items'] as List?)?.cast>() ?? []; + final today = now.weekday % 7; + final todayItem = items.cast?>().firstWhere( + (i) => i?['dayOfWeek'] == today, orElse: () => null); + if (todayItem != null && todayItem['isRestDay'] != true) { + hasExercise = true; + final done = todayItem['isCompleted'] == true; + final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; + final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30'; + tasks.add(_taskRow(context, Icons.directions_run, '$name $dur分钟', + status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'), + iconColor: exIconColor, iconBg: exIconBg, + onTap: () => pushRoute(ref, 'exercisePlan'), + )); + } + } + }); + if (!hasExercise) { + tasks.add(_taskRow(context, Icons.directions_run, '运动', + trailing: '暂无今日运动计划', + status: 'pending', iconColor: exIconColor, iconBg: exIconBg, + onTap: () => pushRoute(ref, 'exercisePlan'))); + } + + // ── 3. 用药打卡 ── + const medIconColor = Color(0xFFEC4899); + const medIconBg = Color(0xFFFCE7F3); reminders.whenOrNull(data: (meds) { final overdue = meds.where((m) => m['status'] == 'overdue').length; final upcoming = meds.where((m) => m['status'] == 'upcoming').length; @@ -1284,57 +1311,27 @@ class ChatMessagesView extends ConsumerWidget { if (overdue > 0) parts.add('$overdue项过期'); if (upcoming > 0) parts.add('$upcoming项待服'); if (taken > 0) parts.add('$taken项已服'); - tasks.add(_taskRow( - context, Icons.medication_rounded, '用药打卡 ($total项)', + tasks.add(_taskRow(context, Icons.medication_rounded, '用药打卡 ($total项)', trailing: parts.join(' · '), status: overdue > 0 ? 'overdue' : upcoming > 0 ? 'pending' : 'done', + iconColor: medIconColor, iconBg: medIconBg, onTap: () => pushRoute(ref, 'medCheckIn'), )); } }); final hasMeds = reminders.asData?.value != null && (reminders.asData!.value).isNotEmpty; if (!hasMeds) { - tasks.add(_taskRow(context, Icons.medication_rounded, '暂无用药提醒', status: 'pending')); - } - - // 3. 运动 — 使用 currentExercisePlanProvider 同步读取 - final exercisePlan = ref.watch(currentExercisePlanProvider); - exercisePlan.whenOrNull(data: (plan) { - if (plan != null) { - final items = (plan['items'] as List?)?.cast>() ?? []; - final today = now.weekday % 7; // Dart: Mon=1...Sun=7 → C#: Sun=0 Mon=1...Sat=6 - final todayItem = items.cast?>().firstWhere( - (i) => i?['dayOfWeek'] == today, orElse: () => null); - if (todayItem != null && todayItem['isRestDay'] != true) { - final done = todayItem['isCompleted'] == true; - final name = items.isNotEmpty ? (items.first['exerciseType']?.toString() ?? '运动') : '运动'; - final dur = items.isNotEmpty ? (items.first['durationMinutes']?.toString() ?? '30') : '30'; - tasks.add(_taskRow(context, Icons.directions_run, - '$name $dur分钟', - status: done ? 'done' : (now.hour >= 18 ? 'overdue' : 'pending'), - onTap: () => pushRoute(ref, 'exercisePlan'), - )); - } - } - }); - - // 4. 异常指标 - if (bp is Map) { - final s = bp['systolic']; - if (s is int && s >= 140) { - tasks.add(_taskRow( - context, Icons.warning_amber_rounded, '血压 $s/${bp['diastolic'] ?? '--'} 偏高', - status: 'warning', - onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), - )); - } + tasks.add(_taskRow(context, Icons.medication_rounded, '用药打卡', + trailing: '暂无用药提醒', + status: 'pending', iconColor: medIconColor, iconBg: medIconBg, + onTap: () => pushRoute(ref, 'medications'))); } return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(16), + borderRadius: BorderRadius.circular(28), border: Border.all(color: Color(0xFFE2E8F0), width: 2), boxShadow: AppColors.cardShadow, ), @@ -1365,10 +1362,12 @@ class ChatMessagesView extends ConsumerWidget { ); } - Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap}) { + Widget _taskRow(BuildContext context, IconData icon, String label, {String status = 'pending', String? trailing, VoidCallback? onTap, Color? iconColor, Color? iconBg}) { final colors = {'done': AppTheme.success, 'warning': AppColors.warning, 'pending': AppColors.textHint, 'overdue': AppTheme.error}; final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined, 'overdue': Icons.error}; final isOverdue = status == 'overdue'; + final ic = iconColor ?? AppTheme.primary; + final ibg = iconBg ?? AppTheme.primaryLight; return Padding( padding: const EdgeInsets.only(bottom: 8), child: GestureDetector( @@ -1381,7 +1380,7 @@ class ChatMessagesView extends ConsumerWidget { borderRadius: BorderRadius.circular(8), ), child: Row(crossAxisAlignment: CrossAxisAlignment.center, children: [ - Container(width: 30, height: 30, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 18, color: AppTheme.primary)), + Container(width: 30, height: 30, decoration: BoxDecoration(color: ibg, borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 18, color: ic)), const SizedBox(width: 10), Expanded(child: Text(trailing ?? label, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary))), Icon(icons[status] ?? Icons.circle_outlined, size: 21, color: colors[status] ?? Colors.grey), @@ -1427,8 +1426,9 @@ class _AgentFeature { final IconData icon; final String title; final String desc; + final String? imageAsset; - const _AgentFeature(this.icon, this.title, this.desc); + const _AgentFeature(this.icon, this.title, this.desc, {this.imageAsset}); } class _ConfirmField { @@ -1446,7 +1446,7 @@ class _ConfirmField { final _agentActions = >{ ActiveAgent.health: [ - _AgentAction(label: '查看趋势', icon: Icons.trending_up, isWide: true, route: 'trend'), + _AgentAction(label: '查看健康情况', icon: Icons.trending_up, isWide: true, route: 'trend'), ], ActiveAgent.diet: [ _AgentAction(label: '拍照识别', icon: Icons.camera_alt_outlined, isWide: true, action: 'pickFoodCamera'), diff --git a/health_app/lib/pages/medication/medication_checkin_page.dart b/health_app/lib/pages/medication/medication_checkin_page.dart index d0f8523..b67788d 100644 --- a/health_app/lib/pages/medication/medication_checkin_page.dart +++ b/health_app/lib/pages/medication/medication_checkin_page.dart @@ -35,8 +35,7 @@ class _MedicationCheckInPageState extends ConsumerState { } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true), body: FutureBuilder>>( future: _future, @@ -120,14 +119,16 @@ class _MedicationCheckInPageState extends ConsumerState { GestureDetector( onTap: () => _toggleDose(medId, time, isTaken), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + width: 40, height: 40, decoration: BoxDecoration( - color: isTaken ? AppColors.successLight : AppTheme.primary, - borderRadius: BorderRadius.circular(20), + color: isTaken ? AppColors.successLight : AppColors.cardInner, + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + isTaken ? Icons.check_circle : Icons.check_circle_outline, + size: 28, + color: isTaken ? AppTheme.success : AppColors.textHint, ), - child: Text(isTaken ? '已打卡' : '打卡', - style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, - color: isTaken ? AppTheme.success : AppColors.textOnGradient)), ), ), ]), diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart index 1999103..09a1223 100644 --- a/health_app/lib/pages/medication/medication_edit_page.dart +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -72,8 +72,7 @@ class _MedicationEditPageState extends ConsumerState { } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(title: Text(widget.id != null ? '编辑用药' : '添加用药')), body: ListView(padding: const EdgeInsets.all(16), children: [ // 名称+剂量 一行 @@ -95,8 +94,8 @@ class _MedicationEditPageState extends ConsumerState { if (t != null) setState(() => _times[i] = t); }, child: Container(padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), - child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 18, color: AppTheme.primary, fontWeight: FontWeight.w600))), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.border, width: 1)), + child: Text('${_times[i].hour.toString().padLeft(2,'0')}:${_times[i].minute.toString().padLeft(2,'0')}', style: const TextStyle(fontSize: 18, color: AppColors.textPrimary, fontWeight: FontWeight.w600))), ))), const SizedBox(height: 16), // 开始+结束 一行 @@ -108,7 +107,7 @@ class _MedicationEditPageState extends ConsumerState { const SizedBox(height: 16), _field('备注', _notesCtrl, hint: '如: 饭后服用、睡前'), const SizedBox(height: 32), - SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 19)))), + SizedBox(width: double.infinity, child: ElevatedButton(onPressed: _loading ? null : _save, style: ElevatedButton.styleFrom(backgroundColor: Colors.white, foregroundColor: AppColors.primary, side: const BorderSide(color: AppColors.primary, width: 1.5), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), padding: const EdgeInsets.symmetric(vertical: 14)), child: Text(_loading ? '保存中...' : '保存', style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)))), const SizedBox(height: 20), ]), ); @@ -119,7 +118,7 @@ class _MedicationEditPageState extends ConsumerState { _label(label), const SizedBox(height: 6), TextField(controller: ctrl, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppTheme.surface, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12)), style: const TextStyle(fontSize: 19)), ]); - Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? AppTheme.primary : AppTheme.primaryLight, borderRadius: BorderRadius.circular(16)), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? Colors.white : AppTheme.primary, fontWeight: FontWeight.w500)))); + Widget _chip(String label, bool sel, VoidCallback onTap) => GestureDetector(onTap: onTap, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), decoration: BoxDecoration(color: sel ? Colors.white : AppColors.cardInner, borderRadius: BorderRadius.circular(16), border: sel ? Border.all(color: AppColors.primary, width: 1.5) : null), child: Text(label, style: TextStyle(fontSize: 16, color: sel ? AppColors.primary : AppColors.textSecondary, fontWeight: FontWeight.w500)))); Widget _dateField(String label, DateTime val, ValueChanged cb) => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ _label(label), const SizedBox(height: 6), GestureDetector(onTap: () async { final d = await showDatePicker(context: context, initialDate: val, firstDate: DateTime(2024), lastDate: DateTime(2030)); if (d != null) cb(d); }, diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index 3c2c841..756d538 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -1,11 +1,10 @@ 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_theme.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; import '../../widgets/app_empty_state.dart'; -import '../../widgets/app_tab_chip.dart'; import '../../widgets/common_widgets.dart'; class MedicationListPage extends ConsumerStatefulWidget { @@ -13,11 +12,23 @@ class MedicationListPage extends ConsumerStatefulWidget { @override ConsumerState createState() => _MedicationListPageState(); } -class _MedicationListPageState extends ConsumerState { +class _MedicationListPageState extends ConsumerState with SingleTickerProviderStateMixin { String _filter = ''; Future>>? _future; + late final _tabCtrl = TabController(length: 3, vsync: this); + + @override void initState() { + super.initState(); + _tabCtrl.addListener(() { + if (!_tabCtrl.indexIsChanging) { + setState(() { _filter = ['', 'active', 'inactive'][_tabCtrl.index]; }); + _load(); + } + }); + _load(); + } + @override void dispose() { _tabCtrl.dispose(); super.dispose(); } - @override void initState() { super.initState(); _load(); } void _load() { setState(() { _future = ref.read(medicationServiceProvider).getMedications(_filter); }); } Future _delete(String id) async { @@ -26,100 +37,101 @@ class _MedicationListPageState extends ConsumerState { _load(); } - static const _tabs = [('全部', ''), ('服用中', 'active'), ('已停药', 'inactive')]; + static const _tabs = ['全部', '服用中', '已停药']; @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: AppTheme.bg, appBar: AppBar( leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('用药管理'), - actions: [ - ShadButton.ghost( - size: ShadButtonSize.sm, - child: const Text('添加'), - onPressed: () => pushRoute(ref, 'medicationEdit'), + bottom: PreferredSize( + preferredSize: const Size.fromHeight(48), + child: TabBar( + controller: _tabCtrl, + indicatorColor: AppColors.primary, + labelColor: AppColors.primary, + unselectedLabelColor: AppColors.textHint, + labelStyle: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600), + tabs: _tabs.map((t) => Tab(child: Text(t))).toList(), ), - ], - ), - body: Column(children: [ - Padding( - padding: const EdgeInsets.all(AppTheme.sMd), - child: Row(children: _tabs.map((t) => AppTabChip( - label: t.$1, - selected: _filter == t.$2, - onTap: () { _filter = t.$2; _load(); }, - )).toList()), ), - Expanded(child: FutureBuilder>>( - future: _future, - builder: (ctx, snap) { - final list = snap.data ?? []; - if (list.isEmpty) { - return AppEmptyState( - icon: LucideIcons.pill, + ), + floatingActionButton: FloatingActionButton( + onPressed: () => pushRoute(ref, 'medicationEdit'), + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.add, size: 28, color: Colors.white), + ), + body: Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: FutureBuilder>>( + future: _future, + builder: (ctx, snap) { + final list = snap.data ?? []; + if (list.isEmpty) { + return const AppEmptyState( + icon: Icons.medication_outlined, title: '暂无用药', - subtitle: '点击右上角添加药品', + subtitle: '点击右下角➕添加药品', ); - } - return ListView.builder( - padding: const EdgeInsets.fromLTRB(0, 0, 0, AppTheme.sMd), - itemCount: list.length + 1, - itemBuilder: (ctx, i) { - if (i == list.length) return const SizedBox(height: 80); - 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(horizontal: AppTheme.sLg, vertical: 4), - child: Container( - padding: const EdgeInsets.all(AppTheme.sLg), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rMd), - boxShadow: [AppTheme.shadowLight], - ), - child: Row(children: [ - Container( - width: 44, height: 44, - decoration: BoxDecoration( - color: isActive ? AppTheme.primaryLight : AppTheme.bgSoft, - borderRadius: BorderRadius.circular(AppTheme.rSm), - ), - child: Icon(LucideIcons.pill, size: 25, color: isActive ? AppTheme.primary : AppTheme.textSub), - ), - const SizedBox(width: AppTheme.sMd), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)), - if (!isActive) ...[ - const SizedBox(width: 6), - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), - decoration: BoxDecoration( - color: AppTheme.bgSoft, - borderRadius: BorderRadius.circular(4), - ), - child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)), - ), - ], - ]), - const SizedBox(height: 2), - Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), - ])), - Icon(LucideIcons.chevronRight, size: 21, color: AppTheme.border), - ]), + } + return ListView.builder( + padding: const EdgeInsets.fromLTRB(0, 8, 0, 80), + itemCount: list.length, + itemBuilder: (ctx, 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(horizontal: AppTheme.sLg, vertical: 4), + child: Container( + padding: const EdgeInsets.all(AppTheme.sLg), + decoration: BoxDecoration( + color: AppTheme.surface, + borderRadius: BorderRadius.circular(AppTheme.rMd), + boxShadow: [AppTheme.shadowLight], ), - ); - }, - ); - }, - )), - ]), + child: Row(children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.rSm), + ), + child: Icon(Icons.medication_outlined, size: 25, color: isActive ? AppColors.primary : AppTheme.textSub), + ), + const SizedBox(width: AppTheme.sMd), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [ + Text(m['name']?.toString() ?? '', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: isActive ? AppTheme.text : AppTheme.textSub)), + if (!isActive) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1), + decoration: BoxDecoration( + color: AppTheme.bgSoft, + borderRadius: BorderRadius.circular(4), + ), + child: Text('已停', style: TextStyle(fontSize: 13, color: AppTheme.textSub)), + ), + ], + ]), + const SizedBox(height: 2), + Text('${m['dosage'] ?? ''} $times', style: TextStyle(fontSize: 16, color: AppTheme.textSub)), + ])), + Icon(Icons.chevron_right, size: 21, color: AppTheme.border), + ]), + ), + ); + }, + ); + }, + ), + ), ); } } diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index a7e2db5..5a91e4a 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -14,17 +14,10 @@ class ProfilePage extends ConsumerWidget { final name = user?.name?.isNotEmpty == true ? user!.name! : '未设置昵称'; final phone = user?.phone ?? ''; - return Scaffold( - backgroundColor: AppColors.background, + return GradientScaffold( appBar: AppBar( backgroundColor: AppColors.cardBackground, title: const Text('个人信息'), - actions: [ - TextButton( - onPressed: () => pushRoute(ref, 'healthArchive'), - child: const Text('编辑档案', style: TextStyle(fontSize: 15, color: AppColors.primary)), - ), - ], ), body: SafeArea( child: SingleChildScrollView( @@ -34,35 +27,14 @@ class ProfilePage extends ConsumerWidget { // 头像区 Center( - child: Stack(children: [ - Container( - padding: const EdgeInsets.all(3), - decoration: BoxDecoration( - gradient: AppColors.gPurplePink, - shape: BoxShape.circle, - ), - child: CircleAvatar( - radius: 50, - backgroundColor: AppColors.cardBackground, - backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, - child: user?.avatarUrl == null - ? const Icon(Icons.person, size: 48, color: AppColors.primary) - : null, - ), - ), - Positioned( - right: 0, bottom: 0, - child: Container( - width: 34, height: 34, - decoration: BoxDecoration( - gradient: AppColors.gPurplePink, - shape: BoxShape.circle, - border: Border.all(color: AppColors.cardBackground, width: 3), - ), - child: const Icon(Icons.camera_alt, size: 17, color: AppColors.textOnGradient), - ), - ), - ]), + child: CircleAvatar( + radius: 50, + backgroundColor: AppColors.cardInner, + backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, + child: user?.avatarUrl == null + ? const Icon(Icons.person, size: 48, color: AppColors.textHint) + : null, + ), ), const SizedBox(height: 20), Text(name, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), @@ -70,13 +42,13 @@ class ProfilePage extends ConsumerWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 6), decoration: BoxDecoration( - color: AppColors.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.rPill), + color: Colors.white, + borderRadius: BorderRadius.circular(20), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ - const Icon(Icons.phone_android, size: 16, color: AppColors.primary), + const Icon(Icons.phone_android, size: 16, color: AppColors.textSecondary), const SizedBox(width: 6), - Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.primary)), + Text(phone.isNotEmpty ? phone : '未绑定手机', style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), ]), ), @@ -100,20 +72,23 @@ class ProfilePage extends ConsumerWidget { ]), ), - const SizedBox(height: 24), + const SizedBox(height: 40), - // 快捷入口 - Row(children: [ - Expanded(child: _actionCard(Icons.folder_outlined, '健康档案', () => pushRoute(ref, 'healthArchive'))), - const SizedBox(width: 12), - Expanded(child: _actionCard(Icons.bluetooth_rounded, '蓝牙设备', () => pushRoute(ref, 'devices'))), - ]), - const SizedBox(height: 12), - Row(children: [ - Expanded(child: _actionCard(Icons.settings_outlined, '设置', () => pushRoute(ref, 'settings'))), - const SizedBox(width: 12), - Expanded(child: _actionCard(Icons.logout, '退出登录', () => _logout(context, ref), isDestructive: true)), - ]), + // 退出登录 + SizedBox( + width: 200, + child: OutlinedButton.icon( + onPressed: () => _logout(context, ref), + icon: const Icon(Icons.logout, size: 18), + label: const Text('退出登录', style: TextStyle(fontSize: 16)), + style: OutlinedButton.styleFrom( + foregroundColor: AppColors.error, + side: const BorderSide(color: AppColors.error), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 12), + ), + ), + ), const SizedBox(height: 40), ]), @@ -129,10 +104,10 @@ class ProfilePage extends ConsumerWidget { Container( width: 40, height: 40, decoration: BoxDecoration( - color: AppColors.primaryLight, + color: Colors.white, borderRadius: BorderRadius.circular(10), ), - child: Icon(icon, size: 22, color: AppColors.primary), + child: Icon(icon, size: 22, color: AppColors.textSecondary), ), const SizedBox(width: 14), Expanded( @@ -147,26 +122,6 @@ class ProfilePage extends ConsumerWidget { ); } - Widget _actionCard(IconData icon, String label, VoidCallback onTap, {bool isDestructive = false}) { - final c = isDestructive ? AppColors.error : AppColors.primary; - return GestureDetector( - onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(vertical: 16, horizontal: 16), - decoration: BoxDecoration( - color: AppColors.cardBackground, - borderRadius: BorderRadius.circular(AppTheme.rMd), - boxShadow: [AppTheme.shadowLight], - ), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - Icon(icon, size: 20, color: c), - const SizedBox(width: 8), - Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: isDestructive ? c : AppColors.textPrimary)), - ]), - ), - ); - } - Future _logout(BuildContext context, WidgetRef ref) async { final ok = await showDialog( context: context, diff --git a/health_app/lib/pages/profile/service_package_detail_page.dart b/health_app/lib/pages/profile/service_package_detail_page.dart index a1a3716..60c864f 100644 --- a/health_app/lib/pages/profile/service_package_detail_page.dart +++ b/health_app/lib/pages/profile/service_package_detail_page.dart @@ -12,14 +12,13 @@ class ServicePackageDetailPage extends ConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final package = servicePackages.where((p) => p.id == packageId).firstOrNull; if (package == null) { - return Scaffold( + return GradientScaffold( appBar: AppBar(title: const Text('服务包详情')), body: const Center(child: Text('未找到该服务包')), ); } - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar( title: Text(package.title, style: const TextStyle(fontSize: 19)), centerTitle: true, diff --git a/health_app/lib/pages/remaining_pages.dart b/health_app/lib/pages/remaining_pages.dart index 54cb36f..4e21725 100644 --- a/health_app/lib/pages/remaining_pages.dart +++ b/health_app/lib/pages/remaining_pages.dart @@ -15,36 +15,40 @@ class DietRecordListPage extends ConsumerStatefulWidget { @override ConsumerState createState() => _DietRecordListPageState(); } class _DietRecordListPageState extends ConsumerState { - Future>>? _future; + List> _data = []; + bool _loading = true; @override void initState() { super.initState(); _refresh(); } - void _refresh() { setState(() { _future = ref.read(dietServiceProvider).getRecords(); }); } + Future _refresh() async { + final records = await ref.read(dietServiceProvider).getRecords(); + if (mounted) setState(() { _data = records; _loading = false; }); + } + Future _delete(String id) async { + await ref.read(dietServiceProvider).deleteRecord(id); + if (mounted) setState(() => _data.removeWhere((d) => d['id']?.toString() == id)); + } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食记录')), - body: FutureBuilder>>( - future: _future, - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator(color: AppTheme.primary)); - final data = snap.data ?? []; - if (data.isEmpty) return _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入'); - return ListView.builder( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: _loading + ? const Center(child: CircularProgressIndicator(color: AppTheme.primary)) + : _data.isEmpty + ? _empty(context, '饮食记录', '暂无饮食记录,可通过「拍饮食」录入') + : ListView.builder( padding: const EdgeInsets.symmetric(vertical: 8), - itemCount: data.length, + itemCount: _data.length, itemBuilder: (ctx, i) { - final d = data[i]; + final d = _data[i]; final items = (d['foodItems'] as List?)?.cast>() ?? []; final mealNames = {'Breakfast':'早餐','Lunch':'午餐','Dinner':'晚餐','Snack':'加餐'}; final mealLabel = mealNames[d['mealType']?.toString()] ?? d['mealType']?.toString() ?? ''; final mealIcons = {'Breakfast':'🌅','Lunch':'☀️','Dinner':'🌙','Snack':'🍪'}; return SwipeDeleteTile( key: Key(d['id']?.toString() ?? '$i'), - onDelete: () async { - await ref.read(dietServiceProvider).deleteRecord(d['id']?.toString() ?? ''); - _refresh(); - }, + onDelete: () => _delete(d['id']?.toString() ?? ''), onTap: () => pushRoute(ref, 'dietDetail', params: {'id': d['id']?.toString() ?? ''}), child: Container( padding: const EdgeInsets.all(14), @@ -66,8 +70,7 @@ class _DietRecordListPageState extends ConsumerState { ), ); }, - ); - }, + ), ), ); } @@ -79,8 +82,7 @@ class DietRecordDetailPage extends ConsumerWidget { const DietRecordDetailPage({super.key, required this.id}); @override Widget build(BuildContext context, WidgetRef ref) { final service = ref.watch(dietServiceProvider); - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('饮食详情')), body: FutureBuilder>>( future: service.getRecords(), @@ -143,11 +145,12 @@ class _ExercisePlanPageState extends ConsumerState { } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('运动计划')), floatingActionButton: FloatingActionButton(onPressed: () { pushRoute(ref, 'exerciseCreate'); }, backgroundColor: AppTheme.primary, child: const Icon(Icons.add)), - body: FutureBuilder>>( + body: Container( + decoration: const BoxDecoration(gradient: AppColors.bgGradient), + child: FutureBuilder>>( future: _future, builder: (ctx, snap) { final plans = snap.data ?? []; @@ -188,11 +191,10 @@ class _ExercisePlanPageState extends ConsumerState { ])), GestureDetector( onTap: hasTodayItem ? () => _checkIn(p['id']?.toString() ?? '', todayItem['id']?.toString() ?? '') : null, - child: AnimatedContainer( - duration: const Duration(milliseconds: 300), + child: Container( width: 40, height: 40, decoration: BoxDecoration( - color: todayDone ? AppColors.successLight : (hasTodayItem ? AppTheme.primaryLight : AppColors.cardInner), + color: todayDone ? AppColors.successLight : AppColors.cardInner, borderRadius: BorderRadius.circular(12), ), child: Icon(todayDone ? Icons.check_circle : Icons.check_circle_outline, size: 28, color: todayDone ? AppTheme.success : AppColors.textHint), @@ -204,6 +206,7 @@ class _ExercisePlanPageState extends ConsumerState { }); }, ), + ), ); } } @@ -235,8 +238,7 @@ class _ExercisePlanCreatePageState extends ConsumerState } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('新建计划')), body: ListView(padding: const EdgeInsets.all(16), children: [ _field('运动名称', _nameCtrl, hint: '如: 散步、慢跑、太极'), @@ -256,7 +258,7 @@ class _ExercisePlanCreatePageState extends ConsumerState return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), const SizedBox(height: 6), - TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: AppColors.backgroundSoft, border: OutlineInputBorder(borderRadius: BorderRadius.circular(AppTheme.rMd), borderSide: BorderSide.none)), style: const TextStyle(fontSize: 19)), + TextField(controller: ctrl, keyboardType: number ? TextInputType.number : null, decoration: InputDecoration(hintText: hint, filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide(color: AppColors.border)), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary, width: 1.5))), style: const TextStyle(fontSize: 17)), ]); } @@ -287,7 +289,7 @@ class _FollowUpListPageState extends ConsumerState { @override Widget build(BuildContext context) { - return Scaffold( + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('复查随访'), centerTitle: true), body: FutureBuilder>>( future: _future, @@ -478,14 +480,12 @@ class _HealthArchivePageState extends ConsumerState { @override Widget build(BuildContext context) { if (_loading) { - return Scaffold( - backgroundColor: AppColors.background, + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康档案')), body: const Center(child: CircularProgressIndicator(color: AppColors.primary)), ); } - return Scaffold( - backgroundColor: AppColors.background, + return GradientScaffold( appBar: AppBar( backgroundColor: AppColors.cardBackground, title: const Text('健康档案'), @@ -549,6 +549,7 @@ class _HealthArchivePageState extends ConsumerState { decoration: BoxDecoration( color: AppColors.cardBackground, borderRadius: BorderRadius.circular(AppTheme.rMd), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), boxShadow: [AppTheme.shadowCard], ), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -556,7 +557,7 @@ class _HealthArchivePageState extends ConsumerState { Container( width: 36, height: 36, decoration: BoxDecoration( - color: AppColors.iconBg, + gradient: AppColors.bgGradient, borderRadius: BorderRadius.circular(10), ), child: Icon(icon, size: 20, color: AppColors.primary), @@ -572,7 +573,7 @@ class _HealthArchivePageState extends ConsumerState { Widget _buildField(String label, TextEditingController ctrl, {String? hint}) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textHint)), + Text(label, style: const TextStyle(fontSize: 14, color: AppColors.textSecondary)), const SizedBox(height: 6), TextField( controller: ctrl, @@ -581,18 +582,18 @@ class _HealthArchivePageState extends ConsumerState { hintText: hint, hintStyle: const TextStyle(fontSize: 15, color: AppColors.textHint), filled: true, - fillColor: AppColors.backgroundSoft, + fillColor: Colors.white, contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rSm), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rSm), - borderSide: BorderSide.none, + borderRadius: BorderRadius.circular(10), + borderSide: BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(AppTheme.rSm), + borderRadius: BorderRadius.circular(10), borderSide: const BorderSide(color: AppColors.primary, width: 1.5), ), ), @@ -634,7 +635,7 @@ class _HealthCalendarPageState extends ConsumerState { } @override Widget build(BuildContext context) { - return Scaffold( + return GradientScaffold( appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('健康日历'), centerTitle: true), body: Column(children: [ _buildMonthHeader(), @@ -847,7 +848,7 @@ class StaticTextPage extends ConsumerWidget { © 2025-2026 健康管家团队。保留所有权利。 本软件受中华人民共和国著作权法保护。''', }; - return Scaffold( + return GradientScaffold( appBar: AppBar( backgroundColor: Colors.white, elevation: 0, @@ -869,8 +870,7 @@ class DeviceManagementPage extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final device = ref.watch(omronDeviceProvider); - return Scaffold( - backgroundColor: AppColors.background, + return GradientScaffold( appBar: AppBar( backgroundColor: AppColors.cardBackground, title: const Text('蓝牙血压计'), diff --git a/health_app/lib/pages/report/ai_analysis_page.dart b/health_app/lib/pages/report/ai_analysis_page.dart index d35fb1c..c105437 100644 --- a/health_app/lib/pages/report/ai_analysis_page.dart +++ b/health_app/lib/pages/report/ai_analysis_page.dart @@ -19,7 +19,6 @@ class _AiAnalysisPageState extends ConsumerState { void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(reportProvider.notifier).loadReports(); ref.read(reportProvider.notifier).fetchReportDetail(widget.id); }); } @@ -31,18 +30,20 @@ class _AiAnalysisPageState extends ConsumerState { final reportItem = reports.where((r) => r.id == widget.id).firstOrNull; if (analysis == null) { - return Scaffold( - appBar: AppBar(title: const Text('AI 解读')), - body: const Center(child: CircularProgressIndicator(color: AppTheme.primary)), + return GradientScaffold( + appBar: AppBar(title: const Text('报告解读')), + body: const Center(child: CircularProgressIndicator(color: AppColors.primary)), ); } - return Scaffold( - backgroundColor: AppTheme.bg, + final displayType = _displayName(analysis.reportType); + final isReviewed = reportItem?.status == 'DoctorReviewed'; + + return GradientScaffold( appBar: AppBar( - title: const Text('AI 智能解读'), + title: Text(displayType, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), leading: IconButton( - icon: const Icon(Icons.arrow_back), + icon: const Icon(Icons.arrow_back, color: AppColors.textPrimary), onPressed: () { ref.read(reportProvider.notifier).clearAnalysis(); popRoute(ref); @@ -52,190 +53,123 @@ class _AiAnalysisPageState extends ConsumerState { body: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - // 报告标题 - _buildHeader(analysis), - const SizedBox(height: 16), - // 审核状态 - if (reportItem != null) _buildStatusBadge(reportItem), - if (reportItem != null) const SizedBox(height: 16), - // 指标分析 - _buildIndicators(analysis), - const SizedBox(height: 16), - // AI 总结 - _buildSummary(analysis), - const SizedBox(height: 16), - // 医生审核意见 - if (reportItem != null && reportItem.status == 'DoctorReviewed') - _buildDoctorReview(reportItem), + // ── 1. 指标分析 ── + if (analysis.indicators.isNotEmpty) ...[ + _sectionTitle('指标分析'), + const SizedBox(height: 8), + Container( + decoration: _cardDeco(), + child: Column( + children: analysis.indicators.map((ind) => _indicatorRow(ind)).toList(), + ), + ), + const SizedBox(height: 20), + ], + // ── 2. 综合解读 ── + _sectionTitle('综合解读'), + const SizedBox(height: 8), + Container( + padding: const EdgeInsets.all(16), + decoration: _cardDeco(), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(analysis.summary.isNotEmpty ? analysis.summary : 'AI 正在分析中,请稍后刷新查看', + style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.7)), + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(10)), + child: const Text('以上为AI预解读,具体请以医生诊断为准', + style: TextStyle(fontSize: 13, color: AppColors.textHint)), + ), + ]), + ), + const SizedBox(height: 20), + // ── 3. 医生审核意见 ── + _sectionTitle('医生审核意见'), + const SizedBox(height: 8), + if (isReviewed) + _doctorReviewCard(reportItem!) + else + Container( + padding: const EdgeInsets.all(16), + decoration: _cardDeco(), + child: const Row(children: [ + Icon(Icons.hourglass_empty, size: 18, color: AppColors.textHint), + SizedBox(width: 8), + Text('待审核', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), + Spacer(), + Text('AI 预解读', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + ]), + ), + const SizedBox(height: 30), ]), ), ); } - Widget _buildHeader(ReportAnalysis analysis) { - return Container( - width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg), - boxShadow: [AppTheme.shadowCard]), - child: Row(children: [ - Container(width: 48, height: 48, decoration: BoxDecoration(color: AppTheme.primaryLight, borderRadius: BorderRadius.circular(12)), - child: const Icon(Icons.auto_awesome, size: 28, color: AppTheme.primary)), - const SizedBox(width: 14), - Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w700)), - const SizedBox(height: 4), - const Text('AI 预解读结果 · 待医生确认', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), - ])), - ]), - ); - } + BoxDecoration _cardDeco() => BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: AppColors.cardShadowLight, + ); - Widget _buildStatusBadge(ReportItem report) { - final isReviewed = report.status == 'DoctorReviewed'; - return Container( - width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: isReviewed ? AppColors.successLight : AppColors.warningLight, - borderRadius: BorderRadius.circular(10), - ), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(isReviewed ? Icons.check_circle : Icons.hourglass_empty, size: 21, - color: isReviewed ? AppTheme.success : AppColors.warning), - const SizedBox(width: 8), - Text(isReviewed ? '已通过医生审核' : '等待医生审核中', - style: TextStyle(fontSize: 17, fontWeight: FontWeight.w500, - color: isReviewed ? AppTheme.success : AppColors.warning)), - ]), - ); - } + Widget _sectionTitle(String text) => Row(children: [ + Container(width: 4, height: 16, decoration: BoxDecoration(color: AppColors.primary, borderRadius: BorderRadius.circular(2))), + const SizedBox(width: 8), + Text(text, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: AppColors.textPrimary)), + ]); - Widget _buildIndicators(ReportAnalysis analysis) { + Widget _indicatorRow(Indicator ind) { + final (color, icon) = switch (ind.status) { + 'high' => (AppColors.error, Icons.arrow_upward), + 'low' => (AppColors.warning, Icons.arrow_downward), + _ => (AppColors.success, Icons.check_circle), + }; return Container( - width: double.infinity, - decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rLg)), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Padding( - padding: EdgeInsets.fromLTRB(16, 16, 16, 8), - child: Text('指标分析', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700)), - ), - ...analysis.indicators.map((ind) => _buildIndicatorRow(ind)), - const SizedBox(height: 8), - ]), - ); - } - - Widget _buildIndicatorRow(Indicator ind) { - final Color color; - final IconData icon; - switch (ind.status) { - case 'high': color = AppTheme.error; icon = Icons.arrow_upward; break; - case 'low': color = AppColors.warning; icon = Icons.arrow_downward; break; - default: color = AppTheme.success; icon = Icons.check_circle; - } - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: const BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.borderLight))), child: Row(children: [ Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(ind.name, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w500)), + Text(ind.name, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), if (ind.referenceRange != null && ind.referenceRange!.isNotEmpty) - Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)), + Text('参考值: ${ind.referenceRange}', style: const TextStyle(fontSize: 13, color: AppColors.textHint)), ]), ), - Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ - Text('${ind.value} ${ind.unit}', - style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: color)), - Icon(icon, size: 17, color: color), - ]), + Icon(icon, size: 14, color: color), + const SizedBox(width: 4), + Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: color)), ]), ); } - Widget _buildSummary(ReportAnalysis analysis) { + Widget _doctorReviewCard(ReportItem report) { return Container( - width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(AppTheme.rLg), - border: Border.all(color: AppColors.warning.withAlpha(80))), + padding: const EdgeInsets.all(16), + decoration: _cardDeco(), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('💡', style: TextStyle(fontSize: 21)), - const SizedBox(width: 8), - Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.warning)), - ]), - const SizedBox(height: 10), - Text(analysis.summary.isNotEmpty ? analysis.summary : '暂无 AI 解读结果', - style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)), - const SizedBox(height: 10), - Container( - width: double.infinity, padding: const EdgeInsets.all(10), - decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), - child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准', - style: TextStyle(fontSize: 15, color: AppColors.warning)), - ), - ]), - ); - } - - Widget _buildDoctorReview(ReportItem report) { - return Container( - width: double.infinity, padding: const EdgeInsets.all(16), - decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(AppTheme.rLg), - border: Border.all(color: AppColors.success.withAlpha(80))), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('✅', style: TextStyle(fontSize: 21)), - const SizedBox(width: 8), - Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w700, color: AppColors.success)), - ]), - if (report.doctorName != null) ...[ - const SizedBox(height: 4), - Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)), - ], - if (report.reviewedAt != null) ...[ - const SizedBox(height: 2), - Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 16)}', - style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))), - ], - if (report.severity != null) ...[ - const SizedBox(height: 8), - _severityBadge(report.severity!), - ], + if (report.doctorName != null) + Text('审核医生:${report.doctorName} | ${report.reviewedAt?.toLocal().toString().substring(0, 16) ?? ''}', + style: const TextStyle(fontSize: 14, color: AppColors.textHint)), if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[ const SizedBox(height: 10), Container( - width: double.infinity, padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), - child: Text('💬 ${report.doctorComment!}', - style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)), + child: Text(report.doctorComment!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)), ), ], if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ const SizedBox(height: 8), Container( - width: double.infinity, padding: const EdgeInsets.all(12), - decoration: BoxDecoration(color: AppTheme.surface, borderRadius: BorderRadius.circular(AppTheme.rSm)), - child: Text('📝 ${report.doctorRecommendation!}', - style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(12)), + child: Text(report.doctorRecommendation!, style: const TextStyle(fontSize: 16, color: AppColors.textPrimary, height: 1.5)), ), ], ]), ); } - Widget _severityBadge(String severity) { - final (label, color, bg) = switch (severity) { - 'Normal' => ('🟢 正常', AppTheme.success, AppColors.successLight), - 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight), - 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight), - 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight), - _ => (severity, AppColors.textHint, AppColors.borderLight), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(6)), - child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: color)), - ); - } + String _displayName(String t) => (t == 'Other' || t == 'other') ? '检查报告' : t; } diff --git a/health_app/lib/pages/report/report_pages.dart b/health_app/lib/pages/report/report_pages.dart index 61ce4e6..0c5dc15 100644 --- a/health_app/lib/pages/report/report_pages.dart +++ b/health_app/lib/pages/report/report_pages.dart @@ -115,9 +115,14 @@ class ReportNotifier extends Notifier { final list = (res.data['data'] as List?) ?? []; final reports = list.map((r) { final m = r as Map; + final rawTitle = m['title']?.toString() ?? ''; + final rawCat = m['category']?.toString() ?? ''; + final title = rawTitle.isNotEmpty && rawTitle != 'Other' && rawTitle != 'other' + ? rawTitle + : _catTitle(rawCat); return ReportItem( id: m['id']?.toString() ?? '', - title: m['category']?.toString() ?? '检查报告', + title: title, type: m['fileType']?.toString() ?? 'Image', uploadedAt: DateTime.tryParse(m['createdAt']?.toString() ?? '') ?? DateTime.now(), hasAnalysis: m['aiSummary'] != null, @@ -145,9 +150,14 @@ class ReportNotifier extends Notifier { return; } final indicators = _parseIndicators(m['aiIndicators']?.toString()); + final rawTitle = m['title']?.toString() ?? ''; + final rawCat = m['category']?.toString() ?? ''; + final displayType = rawTitle.isNotEmpty && rawTitle != 'Other' + ? rawTitle + : _catTitle(rawCat); final analysis = ReportAnalysis( reportId: m['id']?.toString() ?? reportId, - reportType: m['category']?.toString() ?? '检查报告', + reportType: displayType, indicators: indicators, summary: m['aiSummary']?.toString() ?? '', ); @@ -209,6 +219,15 @@ class ReportNotifier extends Notifier { fetchReportDetail(reportId); } + Future deleteReport(String id) async { + try { + await ref.read(apiClientProvider).delete('/api/reports/$id'); + loadReports(); + } catch (e) { + debugPrint('[Report] 删除失败: $e'); + } + } + void clearAnalysis() { state = state.copyWith(currentAnalysis: null); } @@ -223,22 +242,22 @@ class ReportListPage extends ConsumerWidget { final state = ref.watch(reportProvider); if (state.isAnalyzing) { - return Scaffold( - appBar: AppBar(title: const Text('看报告')), + return GradientScaffold( + appBar: AppBar(title: const Text('报告管理')), body: const Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ - CircularProgressIndicator(color: AppTheme.primaryLight), + CircularProgressIndicator(color: AppColors.primary), SizedBox(height: 16), - Text('AI 正在分析报告...'), + Text('AI 正在分析报告...', style: TextStyle(fontSize: 16, color: AppColors.textSecondary)), ]), ), ); } - return Scaffold( + return GradientScaffold( appBar: AppBar( leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), - title: const Text('看报告'), + title: const Text('报告管理'), centerTitle: true, ), floatingActionButton: _buildUploadButton(context, ref), @@ -258,52 +277,52 @@ class ReportListPage extends ConsumerWidget { Widget _buildUploadButton(BuildContext context, WidgetRef ref) { return FloatingActionButton( onPressed: () => _showUploadOptions(context, ref), - backgroundColor: AppTheme.primary, - child: const Icon(Icons.add), + backgroundColor: AppColors.primary, + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), + child: const Icon(Icons.add, size: 28, color: Colors.white), ); } void _showUploadOptions(BuildContext context, WidgetRef ref) { showModalBottomSheet( context: context, + backgroundColor: Colors.white, + shape: const RoundedRectangleBorder(borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (ctx) => SafeArea( - child: Wrap(children: [ - ListTile( - leading: const Icon(Icons.camera_alt), - title: const Text('拍照上传'), - onTap: () async { - Navigator.pop(ctx); - final picker = ImagePicker(); - final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); - if (picked != null) { - ref.read(reportProvider.notifier).uploadImage(picked.path); - } - }, - ), - ListTile( - leading: const Icon(Icons.photo_library), - title: const Text('从相册选择'), - onTap: () async { - Navigator.pop(ctx); - final picker = ImagePicker(); - final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); - if (picked != null) { - ref.read(reportProvider.notifier).uploadImage(picked.path); - } - }, - ), - ListTile( - leading: const Icon(Icons.file_open), - title: const Text('上传PDF文件'), - onTap: () async { - Navigator.pop(ctx); - final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']); - if (result != null && result.files.isNotEmpty) { - ref.read(reportProvider.notifier).uploadFile(result.files.first.path!); - } - }, - ), - ]), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Wrap(children: [ + ListTile( + leading: const Icon(Icons.camera_alt_outlined, color: AppColors.primary), + title: const Text('拍照上传', style: TextStyle(fontSize: 17)), + onTap: () async { + Navigator.pop(ctx); + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.camera, imageQuality: 85); + if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path); + }, + ), + ListTile( + leading: const Icon(Icons.photo_library_outlined, color: AppColors.primary), + title: const Text('从相册选择', style: TextStyle(fontSize: 17)), + onTap: () async { + Navigator.pop(ctx); + final picker = ImagePicker(); + final picked = await picker.pickImage(source: ImageSource.gallery, imageQuality: 85); + if (picked != null) ref.read(reportProvider.notifier).uploadImage(picked.path); + }, + ), + ListTile( + leading: const Icon(Icons.picture_as_pdf_outlined, color: AppColors.primary), + title: const Text('上传PDF文件', style: TextStyle(fontSize: 17)), + onTap: () async { + Navigator.pop(ctx); + final result = await FilePicker.platform.pickFiles(type: FileType.custom, allowedExtensions: ['pdf']); + if (result != null && result.files.isNotEmpty) ref.read(reportProvider.notifier).uploadFile(result.files.first.path!); + }, + ), + ]), + ), ), ); } @@ -312,95 +331,97 @@ class ReportListPage extends ConsumerWidget { return Center( child: Column(mainAxisSize: MainAxisSize.min, children: [ Container( - width: 120, - height: 120, - decoration: BoxDecoration( - color: AppColors.primaryLight, - borderRadius: BorderRadius.circular(60), - ), - child: const Icon(Icons.file_open, size: 48, color: AppTheme.primaryLight), + width: 100, height: 100, + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(50)), + child: const Icon(Icons.description_outlined, size: 44, color: AppColors.textHint), ), const SizedBox(height: 20), - const Text('暂无检查报告', style: TextStyle(fontSize: 21, fontWeight: FontWeight.w500)), + const Text('暂无检查报告', style: TextStyle(fontSize: 20, fontWeight: FontWeight.w500, color: AppColors.textPrimary)), const SizedBox(height: 8), - const Text('点击下方按钮上传报告', style: TextStyle(fontSize: 17, color: AppColors.textHint)), + const Text('点击右下角按钮上传报告', style: TextStyle(fontSize: 16, color: AppColors.textHint)), ]), ); } Widget _buildReportCard(BuildContext context, WidgetRef ref, ReportItem report) { + final displayTitle = (report.title == 'Other' || report.title == 'other') ? '检查报告' : report.title; return Container( margin: const EdgeInsets.only(bottom: 12), decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rLg), - boxShadow: [AppTheme.shadowCard], + color: Colors.white, + borderRadius: BorderRadius.circular(20), + boxShadow: AppColors.cardShadowLight, ), - child: ListTile( - leading: Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: AppColors.primaryLight, - borderRadius: BorderRadius.circular(12), - ), - child: _getReportIcon(report.type), - ), - title: Text(report.title, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), - subtitle: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(report.type, style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), - Text(_formatDate(report.uploadedAt), style: TextStyle(fontSize: 15, color: AppColors.textHint)), - ]), - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (report.status == 'DoctorReviewed') + child: Material( + color: Colors.transparent, + borderRadius: BorderRadius.circular(20), + child: InkWell( + onTap: () => pushRoute(ref, 'aiAnalysis', params: {'id': report.id}), + borderRadius: BorderRadius.circular(20), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row(children: [ Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.successLight, - borderRadius: BorderRadius.circular(4), - ), - child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success)), - ) - else if (!report.hasAnalysis) - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppTheme.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.rXs), - ), - child: Text('分析中', style: TextStyle(fontSize: 13, color: AppTheme.primary)), - ) - else if (report.status == 'PendingDoctor') - Container( - padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), - decoration: BoxDecoration( - color: AppColors.warningLight, - borderRadius: BorderRadius.circular(4), - ), - child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning)), + width: 48, height: 48, + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12), border: Border.all(color: AppColors.borderLight)), + child: Icon(Icons.description_outlined, size: 26, color: AppColors.primary), ), - const SizedBox(width: 8), - report.hasAnalysis - ? Icon(Icons.check_circle, size: 23, color: AppColors.success) - : Icon(Icons.arrow_forward_ios, size: 21, color: AppColors.textHint), - ], + const SizedBox(width: 14), + Expanded( + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(displayTitle, style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: AppColors.textPrimary)), + const SizedBox(height: 4), + Text(_formatDate(report.uploadedAt), style: const TextStyle(fontSize: 14, color: AppColors.textHint)), + ]), + ), + if (report.status == 'DoctorReviewed') + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: AppColors.successLight, borderRadius: BorderRadius.circular(8)), + child: const Text('已审核', style: TextStyle(fontSize: 13, color: AppColors.success, fontWeight: FontWeight.w500)), + ) + else if (!report.hasAnalysis) + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: AppColors.cardInner, borderRadius: BorderRadius.circular(8)), + child: const Text('分析中', style: TextStyle(fontSize: 13, color: AppColors.textHint, fontWeight: FontWeight.w500)), + ) + else + Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), + decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(8)), + child: const Text('待审核', style: TextStyle(fontSize: 13, color: AppColors.warning, fontWeight: FontWeight.w500)), + ), + const SizedBox(width: 4), + GestureDetector( + onTap: () => _confirmDelete(context, ref, report.id), + child: const Padding( + padding: EdgeInsets.all(6), + child: Icon(Icons.delete_outline, size: 20, color: AppColors.textHint), + ), + ), + ]), + ), ), - onTap: () => pushRoute(ref, 'reportDetail', params: {'id': report.id}), ), ); } - Widget _getReportIcon(String type) { - final icons = { - '血液检查': const Icon(Icons.bloodtype, size: 28, color: AppTheme.primaryLight), - '心电图': const Icon(Icons.monitor_heart, size: 28, color: AppTheme.primaryLight), - '超声检查': const Icon(Icons.image, size: 28, color: AppTheme.primaryLight), - '影像报告': const Icon(Icons.image, size: 28, color: AppTheme.primaryLight), - 'PDF文档': const Icon(Icons.picture_as_pdf, size: 28, color: AppTheme.primaryLight), - }; - return icons[type] ?? const Icon(Icons.description, size: 28, color: AppTheme.primaryLight); + 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)), + ), + ], + ), + ); } String _formatDate(DateTime date) { @@ -413,273 +434,13 @@ class ReportListPage extends ConsumerWidget { } } -/// 报告详情页 -class ReportDetailPage extends ConsumerStatefulWidget { - final String id; - const ReportDetailPage({super.key, required this.id}); +String _catTitle(String c) => switch (c) { + 'BloodTest' => '抽血化验单', + 'Biochemistry' => '生化检验报告', + 'Ecg' => '心电图报告', + 'Ultrasound' => '超声检查报告', + 'Discharge' => '出院小结', + 'Image' => '影像检查报告', + _ => '检查报告', +}; - @override - ConsumerState createState() => _ReportDetailPageState(); -} - -class _ReportDetailPageState extends ConsumerState { - @override - void initState() { - super.initState(); - // 进入页面时刷新报告列表(获取最新审核状态)+ 获取详情 - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(reportProvider.notifier).loadReports(); - ref.read(reportProvider.notifier).fetchReportDetail(widget.id); - }); - } - - @override - Widget build(BuildContext context) { - final analysis = ref.watch(reportProvider.select((s) => s.currentAnalysis)); - final reports = ref.watch(reportProvider.select((s) => s.reports)); - final reportItem = reports.where((r) => r.id == widget.id).firstOrNull; - - if (analysis == null) { - return Scaffold( - appBar: AppBar(title: const Text('报告详情')), - body: const Center(child: CircularProgressIndicator(color: AppTheme.primaryLight)), - ); - } - - return Scaffold( - appBar: AppBar( - title: const Text('报告解读'), - leading: IconButton( - icon: const Icon(Icons.arrow_back), - onPressed: () { - ref.read(reportProvider.notifier).clearAnalysis(); - popRoute(ref); - }, - ), - ), - body: SingleChildScrollView( - padding: const EdgeInsets.all(16), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildReportHeader(analysis), - const SizedBox(height: 20), - if (reportItem != null && reportItem.status == 'DoctorReviewed') ...[ - _buildDoctorReview(reportItem), - const SizedBox(height: 20), - ], - _buildAnalysisSection(analysis), - const SizedBox(height: 20), - _buildSummarySection(analysis), - const SizedBox(height: 20), - SizedBox( - width: double.infinity, height: 48, - child: ElevatedButton( - onPressed: () => pushRoute(ref, 'aiAnalysis', params: {'id': widget.id}), - style: ElevatedButton.styleFrom(backgroundColor: AppTheme.primary, foregroundColor: Colors.white, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), - child: const Text('查看 AI 智能解读'), - ), - ), - const SizedBox(height: 30), - ]), - ), - ); - } - - Widget _buildDoctorReview(ReportItem report) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.successLight, - borderRadius: BorderRadius.circular(AppTheme.rLg), - border: Border.all(color: AppColors.success.withAlpha(50)), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('✅', style: TextStyle(fontSize: 23)), - const SizedBox(width: 8), - const Text('医生审核意见', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.success)), - ]), - if (report.doctorName != null) ...[ - const SizedBox(height: 4), - Text('审核医生:${report.doctorName}', style: TextStyle(fontSize: 16, color: AppColors.success)), - ], - if (report.reviewedAt != null) ...[ - const SizedBox(height: 2), - Text('审核时间:${report.reviewedAt!.toLocal().toString().substring(0, 19)}', style: TextStyle(fontSize: 15, color: AppColors.success.withAlpha(180))), - ], - if (report.severity != null) ...[ - const SizedBox(height: 6), - _severityBadge(report.severity!), - ], - if (report.doctorComment != null && report.doctorComment!.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: Text('💬 ${report.doctorComment!}', style: TextStyle(fontSize: 18, color: AppColors.textPrimary, height: 1.5)), - ), - ], - if (report.doctorRecommendation != null && report.doctorRecommendation!.isNotEmpty) ...[ - const SizedBox(height: 8), - Container( - width: double.infinity, - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rMd), - ), - child: Text('📝 ${report.doctorRecommendation!}', style: TextStyle(fontSize: 17, color: AppColors.textPrimary, height: 1.5)), - ), - ], - ]), - ); - } - - Widget _severityBadge(String severity) { - final (label, color, bg) = switch (severity) { - 'Normal' => ('🟢 正常', AppColors.success, AppColors.successLight), - 'Abnormal' => ('🟡 轻度异常', AppColors.warning, AppColors.warningLight), - 'Severe' => ('🟠 中度异常', AppColors.error, AppColors.errorLight), - 'Critical' => ('🔴 重度异常', AppColors.error, AppColors.errorLight), - _ => (severity, AppColors.textSecondary, AppColors.border), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(AppTheme.rXs)), - child: Text(label, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: color)), - ); - } - - Widget _buildReportHeader(ReportAnalysis analysis) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.primaryLight, - borderRadius: BorderRadius.circular(AppTheme.rLg), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('📋', style: TextStyle(fontSize: 28)), - const SizedBox(width: 12), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(analysis.reportType, style: const TextStyle(fontSize: 21, fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - Text('AI 预解读结果', style: TextStyle(fontSize: 17, color: AppColors.textSecondary)), - ]), - ]), - ]), - ); - } - - Widget _buildAnalysisSection(ReportAnalysis analysis) { - return Container( - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rLg), - border: Border.all(color: AppColors.border, width: 1), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(children: [ - const Text('🧪', style: TextStyle(fontSize: 23)), - const SizedBox(width: 8), - const Text('指标分析', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600)), - ]), - ), - Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: AppColors.warningLight, - borderRadius: BorderRadius.circular(AppTheme.rXs), - border: Border.all(color: AppColors.warning.withAlpha(80)), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.info_outline, size: 19, color: AppColors.warning), - const SizedBox(width: 6), - Text( - 'AI 预解读 · 待医生确认', - style: TextStyle(fontSize: 16, color: AppColors.warning, fontWeight: FontWeight.w500), - ), - ], - ), - ), - const SizedBox(height: 12), - ...analysis.indicators.map((ind) => _buildIndicatorRow(ind)), - ]), - ); - } - - Widget _buildIndicatorRow(Indicator ind) { - Color statusColor; - IconData statusIcon; - switch (ind.status) { - case 'high': - statusColor = AppColors.error; - statusIcon = Icons.arrow_upward; - break; - case 'low': - statusColor = AppColors.warning; - statusIcon = Icons.arrow_downward; - break; - default: - statusColor = AppColors.success; - statusIcon = Icons.check_circle; - } - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16), - padding: const EdgeInsets.symmetric(vertical: 12), - decoration: BoxDecoration(border: Border(bottom: BorderSide(color: AppColors.border))), - child: Row(children: [ - Expanded( - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(ind.name, style: const TextStyle(fontSize: 17)), - if (ind.referenceRange != null) - Text('参考值: ${ind.referenceRange}', style: TextStyle(fontSize: 15, color: AppColors.textHint)), - ]), - ), - const SizedBox(width: 16), - Column(children: [ - Text('${ind.value} ${ind.unit}', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: statusColor)), - Icon(statusIcon, size: 19, color: statusColor), - ]), - ]), - ); - } - - Widget _buildSummarySection(ReportAnalysis analysis) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: AppColors.warningLight, - borderRadius: BorderRadius.circular(AppTheme.rLg), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - const Text('💡', style: TextStyle(fontSize: 23)), - const SizedBox(width: 8), - Text('综合解读', style: TextStyle(fontSize: 19, fontWeight: FontWeight.w600, color: AppColors.warning)), - ]), - const SizedBox(height: 12), - Text(analysis.summary, style: TextStyle(fontSize: 17, color: AppColors.warning, height: 1.6)), - const SizedBox(height: 12), - Container( - padding: const EdgeInsets.all(12), - decoration: BoxDecoration( - color: AppTheme.surface, - borderRadius: BorderRadius.circular(AppTheme.rSm), - ), - child: Text('⚠️ AI 解读仅供参考,请以医生诊断为准', style: TextStyle(fontSize: 16, color: AppColors.warning)), - ), - ]), - ); - } -} \ No newline at end of file diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart index 6aeb959..0206623 100644 --- a/health_app/lib/pages/settings/notification_prefs_page.dart +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -50,8 +50,7 @@ class NotificationPrefsPage extends ConsumerWidget { final prefs = ref.watch(notificationPrefsProvider); final dndOn = prefs['dndEnabled'] ?? false; - return Scaffold( - backgroundColor: AppTheme.bg, + return GradientScaffold( appBar: AppBar( backgroundColor: Colors.transparent, elevation: 0, diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index 2f297f3..1489f56 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -11,8 +11,7 @@ class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - return Scaffold( - backgroundColor: AppColors.background, + return GradientScaffold( appBar: AppBar( backgroundColor: AppColors.cardBackground, leading: IconButton(icon: const Icon(LucideIcons.chevronLeft, color: AppColors.textPrimary), onPressed: () => popRoute(ref)), diff --git a/health_app/lib/providers/chat_provider.dart b/health_app/lib/providers/chat_provider.dart index 71ca942..9cab33e 100644 --- a/health_app/lib/providers/chat_provider.dart +++ b/health_app/lib/providers/chat_provider.dart @@ -77,8 +77,7 @@ ActiveAgent _parseAgent(String? type) { class ChatNotifier extends Notifier { StreamSubscription>? _subscription; - String _streamBuffer = ''; - Timer? _streamTimer; + ActiveAgent? _lastTriggeredAgent; void markNeedsRebuild() => state = state.copyWith(); @@ -201,18 +200,20 @@ class ChatNotifier extends Notifier { )]); } - /// 点击胶囊:先出用户标签 → 0.5 秒后出欢迎卡片,不走 AI + /// 点击胶囊:先出用户标签 → 0.4 秒后出欢迎卡片,不走 AI + /// 重复点击同一胶囊不重复弹卡片 void triggerAgent(ActiveAgent agent, String label) { + if (_lastTriggeredAgent == agent) return; + _lastTriggeredAgent = agent; + final userMsg = ChatMessage( id: 'agent_trigger_${DateTime.now().millisecondsSinceEpoch}', role: 'user', content: label, createdAt: DateTime.now(), ); - // 先出用户消息 state = state.copyWith(messages: [...state.messages, userMsg]); - // 短暂延迟后弹出卡片 Future.delayed(const Duration(milliseconds: 400), () { insertAgentWelcome(agent); }); @@ -221,6 +222,7 @@ class ChatNotifier extends Notifier { Future sendImage(String imagePath, String text) async { final file = File(imagePath); if (!await file.exists()) return; + _lastTriggeredAgent = null; // 先显示用户消息(本地显示图片路径) final userMsg = ChatMessage( @@ -264,6 +266,7 @@ class ChatNotifier extends Notifier { Future sendMessage(String text) async { if (text.trim().isEmpty || state.isStreaming) return; + _lastTriggeredAgent = null; final userMsg = ChatMessage( id: '${DateTime.now().millisecondsSinceEpoch}', @@ -333,21 +336,14 @@ class ChatNotifier extends Notifier { case 'conversation_id': state = state.copyWith(conversationId: j['data']?.toString()); case 'answer': - _streamBuffer += (j['data'] as String?) ?? ''; final messageType = j['type'] as String? ?? 'text'; aiMsg.type = _parseMessageType(messageType); if (j['metadata'] is Map) { aiMsg.metadata = Map.from(j['metadata']); } - // 逐字释放 - _streamTimer?.cancel(); - _streamTimer = Timer.periodic(const Duration(milliseconds: 40), (t) { - if (_streamBuffer.isEmpty) { t.cancel(); state = state.copyWith(thinkingText: null); return; } - aiMsg.content += _streamBuffer[0]; - _streamBuffer = _streamBuffer.substring(1); - state = state.copyWith(thinkingText: null); - _update(aiMsg); - }); + aiMsg.content += (j['data'] as String?) ?? ''; + state = state.copyWith(thinkingText: null); + _update(aiMsg); case 'notice': state = state.copyWith(thinkingText: j['message'] as String?); case 'tool_result': @@ -388,12 +384,6 @@ class ChatNotifier extends Notifier { } void _done(ChatMessage m) { - _streamTimer?.cancel(); - // 释放剩余 buffer - while (_streamBuffer.isNotEmpty) { - m.content += _streamBuffer[0]; - _streamBuffer = _streamBuffer.substring(1); - } final u = state.messages.toList(); if (!u.any((x) => x.id == m.id) && m.content.isNotEmpty) u.add(m); state = state.copyWith(messages: u, isStreaming: false, thinkingText: null); diff --git a/health_app/lib/widgets/app_menu_item.dart b/health_app/lib/widgets/app_menu_item.dart index 63fbe72..25dd290 100644 --- a/health_app/lib/widgets/app_menu_item.dart +++ b/health_app/lib/widgets/app_menu_item.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:shadcn_ui/shadcn_ui.dart'; +import '../core/app_colors.dart'; import '../core/app_theme.dart'; /// 统一菜单项,profile_page 和 settings_pages 都共用这个 @@ -37,10 +38,10 @@ class AppMenuItem extends StatelessWidget { width: 38, height: 38, decoration: BoxDecoration( - color: AppTheme.primaryLight, + color: Colors.white, borderRadius: BorderRadius.circular(AppTheme.rSm), ), - child: Icon(icon, size: 22, color: AppTheme.primary), + child: Icon(icon, size: 22, color: AppColors.textPrimary), ), const SizedBox(width: AppTheme.sLg), Expanded( diff --git a/health_app/lib/widgets/app_tab_chip.dart b/health_app/lib/widgets/app_tab_chip.dart index ce0b863..ca5c765 100644 --- a/health_app/lib/widgets/app_tab_chip.dart +++ b/health_app/lib/widgets/app_tab_chip.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import '../core/app_colors.dart'; import '../core/app_theme.dart'; /// 统一样式的标签/筛选 Chip @@ -22,15 +23,16 @@ class AppTabChip extends StatelessWidget { margin: const EdgeInsets.only(right: AppTheme.sSm), padding: const EdgeInsets.symmetric(horizontal: AppTheme.sLg, vertical: AppTheme.sSm), decoration: BoxDecoration( - color: selected ? AppTheme.primary : AppTheme.primaryLight, + color: selected ? Colors.white : AppColors.iconBg, borderRadius: BorderRadius.circular(AppTheme.rPill), + border: selected ? Border.all(color: Color(0xFFC8DDFD), width: 1.5) : null, ), child: Text( label, style: TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: selected ? Colors.white : AppTheme.primary, + color: selected ? AppColors.primary : AppColors.textSecondary, ), ), ), diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index 901763e..608e040 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -44,15 +44,53 @@ class HealthDrawer extends ConsumerWidget { decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(AppTheme.rPill), + boxShadow: AppColors.buttonShadow, ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - child: const Text('服务包', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.workspace_premium_rounded, size: 16, color: Colors.white), + SizedBox(width: 4), + Text('VIP服务', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)), + ]), ), ), const ServicePackageCard(compact: true), ]), ), const SizedBox(height: 8), + // 保险栏 + Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(AppTheme.rMd), + border: Border.all(color: Color(0xFFC8DDFD), width: 1.5), + boxShadow: [AppTheme.shadowCard], + ), + child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 14, 16, 0), + child: Container( + decoration: BoxDecoration( + gradient: AppColors.primaryGradient, + borderRadius: BorderRadius.circular(AppTheme.rPill), + boxShadow: AppColors.buttonShadow, + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.shield_rounded, size: 16, color: Colors.white), + SizedBox(width: 4), + Text('保险', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)), + ]), + ), + ), + const SizedBox(height: 14), + const Padding( + padding: EdgeInsets.fromLTRB(16, 0, 16, 14), + child: Text('即将上线,敬请期待', style: TextStyle(fontSize: 14, color: AppColors.textHint)), + ), + ]), + ), + const SizedBox(height: 8), ], ), ), @@ -141,7 +179,7 @@ class HealthDrawer extends ConsumerWidget { ); } - // ════════════ 健康概览 ════════════ + // ════════════ 健康仪表盘 ════════════ Widget _buildHealthSection(AsyncValue> latestHealth, WidgetRef ref) { return Container( decoration: BoxDecoration( @@ -158,12 +196,13 @@ class HealthDrawer extends ConsumerWidget { decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(AppTheme.rPill), + boxShadow: AppColors.buttonShadow, ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.monitor_heart_rounded, size: 15, color: Colors.white), + Icon(Icons.dashboard_rounded, size: 16, color: Colors.white), SizedBox(width: 4), - Text('健康概览', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + Text('健康仪表盘', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)), ]), ), const Spacer(), @@ -216,9 +255,14 @@ class HealthDrawer extends ConsumerWidget { decoration: BoxDecoration( gradient: AppColors.primaryGradient, borderRadius: BorderRadius.circular(AppTheme.rPill), + boxShadow: AppColors.buttonShadow, ), - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - child: const Text('功能', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Colors.white)), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 5), + child: const Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.apps_rounded, size: 16, color: Colors.white), + SizedBox(width: 4), + Text('功能', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w700, color: Colors.white)), + ]), ), ), Padding( diff --git a/health_app/lib/widgets/service_package_card.dart b/health_app/lib/widgets/service_package_card.dart index ed16f8a..d849039 100644 --- a/health_app/lib/widgets/service_package_card.dart +++ b/health_app/lib/widgets/service_package_card.dart @@ -224,33 +224,8 @@ class ServicePackageCard extends ConsumerWidget { ), ]), const SizedBox(height: 12), - // VIP 标签 - Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), - decoration: BoxDecoration( - gradient: LinearGradient(colors: [const Color(0xFFF5A623).withAlpha(200), const Color(0xFFE8930C)]), - borderRadius: BorderRadius.circular(6), - ), - child: const Row(mainAxisSize: MainAxisSize.min, children: [ - Text('VIP', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: Colors.white)), - SizedBox(width: 4), - Text('VIP 产品权益', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.white)), - ]), - ), - const SizedBox(height: 14), // 服务图标网格 ...serviceRows, - const SizedBox(height: 12), - // 查看更多 - Center( - child: GestureDetector( - onTap: () => pushRoute(ref, 'servicePackageDetail', params: {'id': package.id}), - child: Row(mainAxisSize: MainAxisSize.min, children: [ - Text('查看更多服务包', style: TextStyle(fontSize: 16, color: AppTheme.primary, fontWeight: FontWeight.w500)), - Icon(Icons.chevron_right, size: 19, color: AppTheme.primary), - ]), - ), - ), ], ), ), diff --git a/health_app/pubspec.yaml b/health_app/pubspec.yaml index 427b0ce..d3cbec0 100644 --- a/health_app/pubspec.yaml +++ b/health_app/pubspec.yaml @@ -53,3 +53,5 @@ dev_dependencies: flutter: uses-material-design: true + assets: + - assets/images/