feat: 全面UI改造 — 报告/饮食/运动/用药/侧边栏/对话流/胶囊
- 报告模块:重写AI解读页,统一白色卡片+三栏布局(指标→解读→医生审核);加删除功能+后端删接口;VLM自动识别报告类型生成中文标题;去五颜六色
- 饮食分析:全面重设计,暖橙主题配色,图片自适应,餐次emoji选择器,去紫色
- 运动确认卡片:修复duration_minutes JSON类型转换,支持中文数字识别(三十分钟/半小时→30);主区域只显示运动名,字段行改为横向排列
- 流式输出:简化为最基础逐字追加,去buffer+Timer+淡入动画
- 欢迎卡片:每智能体独立渐变色(青/橙/蓝/紫/绿/粉),加卡片入场滑入+淡入动画(600ms)
- 确认卡片:头部去紫色背景改浅灰白,字段行去图标改纯信息横排,编辑框去双重边框
- 用药管理:胶囊改TabBar,添加按钮改右下FAB;打卡按钮改对号圆圈形式;药丸图标白底
- 侧边栏:加VIP服务/保险栏+图标,标题字体放大;服务包去查看更多+去VIP金色标签
- 胶囊:首页智能体胶囊白底深色字; side胶囊加阴影
- 对话流:reverse=false,今日健康出现在顶部; 对话页input bar匹配主页面样式
- 其他:个人资料去紫色+去多余入口;设置页白底图标;蓝牙页加返回按钮;报告管理改名
- 后端:加DELETE /api/reports/{id}; 修复manage_exercise数据类型转换;AI提示优化中文数字
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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<string, object> 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;
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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._();
|
||||
|
||||
@@ -165,8 +165,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: AppTheme.bg,
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(title: const Text('健康概览'), centerTitle: true),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showAddDialog,
|
||||
|
||||
@@ -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<DoctorChatPage> {
|
||||
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<DoctorChatPage> {
|
||||
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<DoctorChatPage> {
|
||||
|
||||
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),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -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<DeviceScanPage> {
|
||||
// ═══════════════════ 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),
|
||||
|
||||
@@ -81,14 +81,9 @@ class DietNotifier extends Notifier<DietState> {
|
||||
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<DietState> {
|
||||
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<DietState> {
|
||||
}).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<void> 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<int>(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<DietCapturePage> createState() => _DietCapturePageState();
|
||||
}
|
||||
|
||||
class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
@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<DietCapturePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── 分析中指示器 ───────────
|
||||
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<DietCapturePage> {
|
||||
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<String> 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);
|
||||
}
|
||||
@@ -61,7 +61,9 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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<HomePage> {
|
||||
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<HomePage> {
|
||||
(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<HomePage> {
|
||||
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<HomePage> {
|
||||
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<HomePage> {
|
||||
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),
|
||||
|
||||
@@ -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<Offset>(begin: const Offset(0, 0.12), end: Offset.zero).animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
late final _fade = Tween<double>(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<double>(
|
||||
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<String, String> doc, WidgetRef ref) {
|
||||
Widget _doctorCard(Map<String, String> 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 = <Widget>[];
|
||||
|
||||
// 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 = <String>[];
|
||||
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<Map<String, dynamic>>() ?? [];
|
||||
final today = now.weekday % 7;
|
||||
final todayItem = items.cast<Map<String, dynamic>?>().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<Map<String, dynamic>>() ?? [];
|
||||
final today = now.weekday % 7; // Dart: Mon=1...Sun=7 → C#: Sun=0 Mon=1...Sat=6
|
||||
final todayItem = items.cast<Map<String, dynamic>?>().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, List<_AgentAction>>{
|
||||
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'),
|
||||
|
||||
@@ -35,8 +35,7 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
}
|
||||
|
||||
@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<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
@@ -120,14 +119,16 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
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)),
|
||||
),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -72,8 +72,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
}
|
||||
|
||||
@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<MedicationEditPage> {
|
||||
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<MedicationEditPage> {
|
||||
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<MedicationEditPage> {
|
||||
_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<DateTime> 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); },
|
||||
|
||||
@@ -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<MedicationListPage> createState() => _MedicationListPageState();
|
||||
}
|
||||
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
class _MedicationListPageState extends ConsumerState<MedicationListPage> with SingleTickerProviderStateMixin {
|
||||
String _filter = '';
|
||||
Future<List<Map<String, dynamic>>>? _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<void> _delete(String id) async {
|
||||
@@ -26,100 +37,101 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
_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<List<Map<String, dynamic>>>(
|
||||
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<List<Map<String, dynamic>>>(
|
||||
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),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<void> _logout(BuildContext context, WidgetRef ref) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -15,36 +15,40 @@ class DietRecordListPage extends ConsumerStatefulWidget {
|
||||
@override ConsumerState<DietRecordListPage> createState() => _DietRecordListPageState();
|
||||
}
|
||||
class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
List<Map<String, dynamic>> _data = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override void initState() { super.initState(); _refresh(); }
|
||||
void _refresh() { setState(() { _future = ref.read(dietServiceProvider).getRecords(); }); }
|
||||
Future<void> _refresh() async {
|
||||
final records = await ref.read(dietServiceProvider).getRecords();
|
||||
if (mounted) setState(() { _data = records; _loading = false; });
|
||||
}
|
||||
Future<void> _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<List<Map<String, dynamic>>>(
|
||||
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<Map<String, dynamic>>() ?? [];
|
||||
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<DietRecordListPage> {
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -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<List<Map<String, dynamic>>>(
|
||||
future: service.getRecords(),
|
||||
@@ -143,11 +145,12 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
}
|
||||
|
||||
@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<List<Map<String, dynamic>>>(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
|
||||
child: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
final plans = snap.data ?? [];
|
||||
@@ -188,11 +191,10 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
|
||||
])),
|
||||
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<ExercisePlanPage> {
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -235,8 +238,7 @@ class _ExercisePlanCreatePageState extends ConsumerState<ExercisePlanCreatePage>
|
||||
}
|
||||
|
||||
@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<ExercisePlanCreatePage>
|
||||
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<FollowUpListPage> {
|
||||
|
||||
@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<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
@@ -478,14 +480,12 @@ class _HealthArchivePageState extends ConsumerState<HealthArchivePage> {
|
||||
|
||||
@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<HealthArchivePage> {
|
||||
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<HealthArchivePage> {
|
||||
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<HealthArchivePage> {
|
||||
|
||||
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<HealthArchivePage> {
|
||||
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<HealthCalendarPage> {
|
||||
}
|
||||
|
||||
@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('蓝牙血压计'),
|
||||
|
||||
@@ -19,7 +19,6 @@ class _AiAnalysisPageState extends ConsumerState<AiAnalysisPage> {
|
||||
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<AiAnalysisPage> {
|
||||
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<AiAnalysisPage> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -115,9 +115,14 @@ class ReportNotifier extends Notifier<ReportState> {
|
||||
final list = (res.data['data'] as List?) ?? [];
|
||||
final reports = list.map((r) {
|
||||
final m = r as Map<String, dynamic>;
|
||||
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<ReportState> {
|
||||
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<ReportState> {
|
||||
fetchReportDetail(reportId);
|
||||
}
|
||||
|
||||
Future<void> 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<ReportDetailPage> createState() => _ReportDetailPageState();
|
||||
}
|
||||
|
||||
class _ReportDetailPageState extends ConsumerState<ReportDetailPage> {
|
||||
@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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -77,8 +77,7 @@ ActiveAgent _parseAgent(String? type) {
|
||||
|
||||
class ChatNotifier extends Notifier<ChatState> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
String _streamBuffer = '';
|
||||
Timer? _streamTimer;
|
||||
ActiveAgent? _lastTriggeredAgent;
|
||||
|
||||
void markNeedsRebuild() => state = state.copyWith();
|
||||
|
||||
@@ -201,18 +200,20 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
)]);
|
||||
}
|
||||
|
||||
/// 点击胶囊:先出用户标签 → 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<ChatState> {
|
||||
Future<void> 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<ChatState> {
|
||||
|
||||
Future<void> 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<ChatState> {
|
||||
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<String, dynamic>.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<ChatState> {
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<Map<String, dynamic>> 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(
|
||||
|
||||
@@ -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),
|
||||
]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -53,3 +53,5 @@ dev_dependencies:
|
||||
|
||||
flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/images/
|
||||
|
||||
Reference in New Issue
Block a user