Compare commits
18 Commits
36ad334643
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bd0155e17 | ||
|
|
f6c1ea7ec9 | ||
|
|
f46c30f8e7 | ||
|
|
8dcf99cac5 | ||
|
|
f484c6b66a | ||
|
|
7cd79bce68 | ||
|
|
2cb1cf4a9c | ||
|
|
ff96fb6c4c | ||
|
|
ea7226c805 | ||
|
|
15f9a122ca | ||
|
|
e3b9716f7c | ||
|
|
95bf5732f6 | ||
|
|
711b583aaf | ||
|
|
7953cca15d | ||
|
|
07ddf2577a | ||
|
|
0e49b9a952 | ||
|
|
ed716654b3 | ||
|
|
9fb60cb3cf |
@@ -101,6 +101,31 @@ public static class RemainingEndpoints
|
||||
await db.SaveChangesAsync(ct);
|
||||
return Results.Ok(new { code = 0, data = new { success = true }, message = (string?)null });
|
||||
});
|
||||
|
||||
// 获取待提醒的用药
|
||||
group.MapGet("/reminders", async (HttpContext http, AppDbContext db, CancellationToken ct) =>
|
||||
{
|
||||
var userId = GetUserId(http);
|
||||
var now = TimeOnly.FromDateTime(DateTime.Now);
|
||||
var windowEnd = now.AddHours(1);
|
||||
var meds = await db.Medications
|
||||
.Where(m => m.UserId == userId && m.IsActive && m.TimeOfDay != null)
|
||||
.ToListAsync(ct);
|
||||
var due = meds.Where(m => m.TimeOfDay!.Any(t => t >= now && t <= windowEnd)).ToList();
|
||||
|
||||
// 检查今天是否已打卡
|
||||
var today = DateOnly.FromDateTime(DateTime.Now);
|
||||
var dueMeds = new List<object>();
|
||||
foreach (var m in due)
|
||||
{
|
||||
var logged = await db.MedicationLogs.AnyAsync(l =>
|
||||
l.MedicationId == m.Id && l.CreatedAt >= today.ToDateTime(TimeOnly.MinValue) && l.Status == MedicationLogStatus.Taken, ct);
|
||||
if (!logged)
|
||||
dueMeds.Add(new { m.Id, m.Name, m.Dosage, m.TimeOfDay });
|
||||
}
|
||||
|
||||
return Results.Ok(new { code = 0, data = dueMeds, message = (string?)null });
|
||||
});
|
||||
}
|
||||
|
||||
public static void MapReportEndpoints(this WebApplication app)
|
||||
|
||||
@@ -36,8 +36,7 @@ public class AiAgentTests
|
||||
var pm = new PromptManager();
|
||||
var prompt = pm.GetSystemPrompt(AgentType.Default);
|
||||
Assert.Contains("心脏", prompt);
|
||||
Assert.Contains("阿福", prompt);
|
||||
Assert.Contains("温暖", prompt);
|
||||
Assert.Contains("健康", prompt);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
BIN
health_app/flutter_01.png
Normal file
BIN
health_app/flutter_01.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 557 KiB |
BIN
health_app/flutter_02.png
Normal file
BIN
health_app/flutter_02.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 558 KiB |
@@ -1,3 +1,4 @@
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'local_database.dart';
|
||||
|
||||
@@ -54,6 +55,19 @@ class ApiClient {
|
||||
Future<Response> delete(String path) async {
|
||||
return _dio.delete(path);
|
||||
}
|
||||
|
||||
/// 上传文件(multipart),返回文件 URL
|
||||
Future<String?> uploadFile(String path, File file, {String fieldName = 'file'}) async {
|
||||
final formData = FormData.fromMap({
|
||||
fieldName: await MultipartFile.fromFile(file.path, filename: file.path.split('/').last),
|
||||
});
|
||||
final res = await _dio.post(path, data: formData);
|
||||
final data = res.data;
|
||||
if (data is Map) {
|
||||
return data['url']?.toString() ?? data['data']?['url']?.toString();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// 认证拦截器:自动注入 token + 401 刷新
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../pages/report/ai_analysis_page.dart';
|
||||
import '../pages/consultation/consultation_pages.dart';
|
||||
import '../pages/settings/settings_pages.dart';
|
||||
import '../pages/settings/notification_prefs_page.dart';
|
||||
import '../pages/profile/profile_page.dart';
|
||||
import '../pages/profile/profile_detail_page.dart';
|
||||
import '../pages/diet/diet_capture_page.dart';
|
||||
import '../pages/remaining_pages.dart';
|
||||
@@ -28,8 +29,6 @@ Widget buildPage(RouteInfo route) {
|
||||
return const HealthCalendarPage();
|
||||
case 'medications':
|
||||
return const MedicationListPage();
|
||||
case 'medicationAdd':
|
||||
return const MedicationEditPage();
|
||||
case 'medicationEdit':
|
||||
return const MedicationEditPage();
|
||||
case 'reports':
|
||||
@@ -49,9 +48,13 @@ Widget buildPage(RouteInfo route) {
|
||||
case 'dietCapture':
|
||||
return const DietCapturePage();
|
||||
case 'profile':
|
||||
return const ProfileDetailPage();
|
||||
return const ProfilePage();
|
||||
case 'profileEdit':
|
||||
return const ProfileDetailPage();
|
||||
case 'editProfile':
|
||||
return const EditProfilePage();
|
||||
case 'devices':
|
||||
return const DeviceManagementPage();
|
||||
case 'healthArchive':
|
||||
return const HealthArchivePage();
|
||||
case 'followups':
|
||||
|
||||
@@ -1,77 +1,81 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// 健康管家主题配置——薰衣草紫 + 温暖治愈风
|
||||
/// 健康管家 — Lavender Breeze 淡紫清风
|
||||
class AppTheme {
|
||||
AppTheme._();
|
||||
|
||||
static const Color primaryColor = Color(0xFF635BFF);
|
||||
static const Color primaryLight = Color(0xFFEDEBFF);
|
||||
static const Color primaryDark = Color(0xFF4B44D6);
|
||||
static const Color background = Color(0xFFF8F9FF);
|
||||
static const Color cardWhite = Color(0xFFFFFFFF);
|
||||
static const Color textPrimary = Color(0xFF1A1A1A);
|
||||
static const Color textSecondary = Color(0xFF666666);
|
||||
static const Color textPlaceholder = Color(0xFF999999);
|
||||
static const Color successGreen = Color(0xFF43A047);
|
||||
static const Color errorRed = Color(0xFFE53935);
|
||||
static const Color warningYellow = Color(0xFFF9A825);
|
||||
static const Color secondaryButton = Color(0xFFE5E5F7);
|
||||
static const Color primary = Color(0xFF8B9CF7); // 淡薰紫
|
||||
static const Color primaryLight = Color(0xFFF0F2FF); // 极淡紫底
|
||||
static const Color primaryDark = Color(0xFF6A7DE0); // 深薰紫
|
||||
|
||||
static const Color bg = Color(0xFFF8F9FC); // 清透白底
|
||||
static const Color surface = Color(0xFFFFFFFF); // 纯白卡片
|
||||
|
||||
static const Color text = Color(0xFF2D2B32);
|
||||
static const Color textSub = Color(0xFF8A8892);
|
||||
static const Color textHint = Color(0xFFBFBCC4);
|
||||
|
||||
static const Color success = Color(0xFF6ECF8A);
|
||||
static const Color error = Color(0xFFF56C6C);
|
||||
static const Color warning = Color(0xFFF5A623);
|
||||
static const Color accent = Color(0xFFFF8068);
|
||||
|
||||
static const Color border = Color(0xFFEAEAF0);
|
||||
static const Color divider = Color(0xFFF2F2F6);
|
||||
|
||||
/// 每个智能体的卡片色调
|
||||
static const Map<String, Color> agentColors = {
|
||||
'default': Color(0xFFE8ECFF), // 淡蓝紫
|
||||
'consultation': Color(0xFFE8F5FF), // 淡天蓝
|
||||
'health': Color(0xFFE8FFF0), // 淡薄荷
|
||||
'diet': Color(0xFFFFF2E8), // 淡杏
|
||||
'medication': Color(0xFFFFE8F0), // 淡粉
|
||||
'report': Color(0xFFE8F4FF), // 淡水蓝
|
||||
'exercise': Color(0xFFF0E8FF), // 淡紫
|
||||
};
|
||||
|
||||
static Color agentLight(String? name) => agentColors[name] ?? primaryLight;
|
||||
|
||||
static ThemeData get lightTheme => ThemeData(
|
||||
useMaterial3: true,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: primaryColor,
|
||||
primary: primaryColor,
|
||||
surface: background,
|
||||
brightness: Brightness.light,
|
||||
),
|
||||
scaffoldBackgroundColor: background,
|
||||
colorScheme: ColorScheme.fromSeed(seedColor: primary, primary: primary, surface: bg, brightness: Brightness.light),
|
||||
scaffoldBackgroundColor: bg,
|
||||
|
||||
appBarTheme: const AppBarTheme(
|
||||
backgroundColor: cardWhite,
|
||||
foregroundColor: textPrimary,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
color: cardWhite,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
backgroundColor: surface, foregroundColor: text, elevation: 0,
|
||||
centerTitle: true, scrolledUnderElevation: 0,
|
||||
titleTextStyle: TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: text),
|
||||
),
|
||||
|
||||
cardTheme: CardThemeData(color: surface, elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), margin: EdgeInsets.zero),
|
||||
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: cardWhite,
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: secondaryButton, width: 1.5),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: const BorderSide(color: primaryColor, width: 1.5),
|
||||
),
|
||||
hintStyle: const TextStyle(color: textPlaceholder, fontSize: 16),
|
||||
),
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primaryColor,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
textStyle: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
|
||||
),
|
||||
filled: true, fillColor: const Color(0xFFF4F5FA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none),
|
||||
focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: primary, width: 1.5)),
|
||||
hintStyle: const TextStyle(color: textHint, fontSize: 15),
|
||||
),
|
||||
|
||||
elevatedButtonTheme: ElevatedButtonThemeData(style: ElevatedButton.styleFrom(
|
||||
backgroundColor: primary, foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 48),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600), elevation: 0,
|
||||
)),
|
||||
|
||||
dialogTheme: DialogThemeData(shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(22))),
|
||||
|
||||
textTheme: const TextTheme(
|
||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w600, color: textPrimary),
|
||||
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: textPrimary),
|
||||
bodyLarge: TextStyle(fontSize: 18, fontWeight: FontWeight.w400, color: textPrimary),
|
||||
bodyMedium: TextStyle(fontSize: 16, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
labelMedium: TextStyle(fontSize: 14, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
labelSmall: TextStyle(fontSize: 12, fontWeight: FontWeight.w400, color: textSecondary),
|
||||
headlineLarge: TextStyle(fontSize: 24, fontWeight: FontWeight.w700, color: text),
|
||||
titleLarge: TextStyle(fontSize: 20, fontWeight: FontWeight.w600, color: text),
|
||||
titleMedium: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: text),
|
||||
bodyLarge: TextStyle(fontSize: 16, color: text, height: 1.5),
|
||||
bodyMedium: TextStyle(fontSize: 15, color: textSub, height: 1.4),
|
||||
labelMedium: TextStyle(fontSize: 13, color: textSub),
|
||||
labelSmall: TextStyle(fontSize: 11, color: textHint),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -50,11 +50,11 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
|
||||
return Scaffold(
|
||||
body: Container(
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF5F3FF), Color(0xFFEDEBFF), Color(0xFFE8E4FF)])),
|
||||
decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF0F2FF), Color(0xFFF0F2FF), Color(0xFFE8E4FF)])),
|
||||
child: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.symmetric(horizontal: 32), child: Column(children: [
|
||||
const SizedBox(height: 60),
|
||||
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
|
||||
Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF635BFF))),
|
||||
Container(width: 140, height: 140, decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(20), borderRadius: BorderRadius.circular(70)), child: Stack(alignment: Alignment.center, children: [
|
||||
Container(width: 100, height: 100, decoration: BoxDecoration(color: Colors.white.withAlpha(200), borderRadius: BorderRadius.circular(50)), child: Icon(Icons.favorite, size: 50, color: const Color(0xFF8B9CF7))),
|
||||
Positioned(right: 10, top: 10, child: Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFFFB800), borderRadius: BorderRadius.circular(15), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.add, size: 16, color: Colors.white))),
|
||||
])),
|
||||
const SizedBox(height: 24),
|
||||
@@ -63,22 +63,22 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
Text('你的 AI 心脏健康管家', style: TextStyle(fontSize: 15, color: Colors.grey[500])),
|
||||
const SizedBox(height: 48),
|
||||
TextField(controller: _phoneCtrl, keyboardType: TextInputType.phone, maxLength: 11,
|
||||
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF635BFF), width: 1.5)))),
|
||||
decoration: InputDecoration(hintText: '请输入手机号', prefixIcon: const Padding(padding: EdgeInsets.only(left: 12), child: Text('+86', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500))), counterText: '', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)))),
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(child: TextField(controller: _codeCtrl, keyboardType: TextInputType.number, maxLength: 6,
|
||||
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF635BFF), width: 1.5)), counterText: ''))),
|
||||
decoration: InputDecoration(hintText: '验证码', filled: true, fillColor: Colors.white, border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), enabledBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none), focusedBorder: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: const BorderSide(color: Color(0xFF8B9CF7), width: 1.5)), counterText: ''))),
|
||||
const SizedBox(width: 12),
|
||||
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF635BFF), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
|
||||
GestureDetector(onTap: (_countdown > 0 || _sending) ? null : _sendSms, child: Container(width: 100, height: 48, alignment: Alignment.center, decoration: BoxDecoration(color: _countdown > 0 ? Colors.grey[300] : const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(12)), child: Text(_sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : Colors.white, fontWeight: FontWeight.w500)))),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
Align(alignment: Alignment.centerLeft, child: GestureDetector(onTap: () => setState(() => _agreed = !_agreed), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF635BFF) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF635BFF) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF635BFF)))])),
|
||||
Container(width: 20, height: 20, margin: const EdgeInsets.only(right: 6), decoration: BoxDecoration(shape: BoxShape.rectangle, color: _agreed ? const Color(0xFF8B9CF7) : Colors.transparent, border: Border.all(color: _agreed ? const Color(0xFF8B9CF7) : const Color(0xFFBDBDBD), width: 1.5), borderRadius: BorderRadius.circular(4)), child: _agreed ? const Icon(Icons.check, size: 14, color: Colors.white) : null),
|
||||
RichText(text: TextSpan(children: [TextSpan(text: '已阅读并同意', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《服务协议》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7))), TextSpan(text: '和', style: TextStyle(fontSize: 13, color: Colors.grey[600])), TextSpan(text: '《隐私政策》', style: const TextStyle(fontSize: 13, color: Color(0xFF8B9CF7)))])),
|
||||
]))),
|
||||
if (_error != null) Padding(padding: const EdgeInsets.only(top: 12), child: Text(_error!, style: const TextStyle(color: Color(0xFFE53935), fontSize: 13))),
|
||||
const SizedBox(height: 24),
|
||||
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFF7C73FF), Color(0xFF635BFF)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
|
||||
GestureDetector(onTap: _loading ? null : _login, child: Container(width: double.infinity, height: 50, alignment: Alignment.center, decoration: BoxDecoration(gradient: const LinearGradient(colors: [Color(0xFFA8B5FA), Color(0xFF8B9CF7)]), borderRadius: BorderRadius.circular(25), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(80), blurRadius: 16, offset: const Offset(0, 8))]), child: _loading ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2.5, color: Colors.white)) : const Text('登 录', style: TextStyle(fontSize: 17, color: Colors.white, fontWeight: FontWeight.w600, letterSpacing: 2)))),
|
||||
const SizedBox(height: 40),
|
||||
]))),
|
||||
),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
/// 医生列表页
|
||||
@@ -35,10 +36,10 @@ class DoctorListPage extends ConsumerWidget {
|
||||
child: Row(children: [
|
||||
CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFEDEBFF),
|
||||
backgroundColor: const Color(0xFFF0F2FF),
|
||||
child: Text(
|
||||
(d['name'] as String?)?.isNotEmpty == true ? d['name']![0] : '?',
|
||||
style: const TextStyle(fontSize: 22, color: Color(0xFF635BFF)),
|
||||
style: const TextStyle(fontSize: 22, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -52,16 +53,14 @@ class DoctorListPage extends ConsumerWidget {
|
||||
Text(d['title'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))),
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF635BFF))),
|
||||
Text(d['department'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF8B9CF7))),
|
||||
const SizedBox(height: 2),
|
||||
Text(d['introduction'] ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF999999))),
|
||||
],
|
||||
),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () async {
|
||||
// TODO: 点击「咨询」创建问诊并跳转聊天页
|
||||
},
|
||||
onPressed: () => pushRoute(ref, 'consultation', params: {'id': d['id']?.toString() ?? ''}),
|
||||
child: const Text('咨询'),
|
||||
),
|
||||
]),
|
||||
|
||||
@@ -132,11 +132,11 @@ class DietCapturePage extends ConsumerWidget {
|
||||
width: 180,
|
||||
height: 180,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(90),
|
||||
border: Border.all(color: const Color(0xFF635BFF), width: 2),
|
||||
border: Border.all(color: const Color(0xFF8B9CF7), width: 2),
|
||||
),
|
||||
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF635BFF)),
|
||||
child: const Icon(Icons.camera_alt, size: 48, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
const Text('拍摄或上传您的餐食照片', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
@@ -165,10 +165,10 @@ class DietCapturePage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(20), blurRadius: 8, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: IconButton(
|
||||
icon: Icon(icon, size: 32, color: const Color(0xFF635BFF)),
|
||||
icon: Icon(icon, size: 32, color: const Color(0xFF8B9CF7)),
|
||||
onPressed: () => _pickImage(context, ref, source),
|
||||
),
|
||||
),
|
||||
@@ -244,11 +244,11 @@ class DietCapturePage extends ConsumerWidget {
|
||||
child: Column(children: [
|
||||
Text(meal['icon']!, style: const TextStyle(fontSize: 20)),
|
||||
const SizedBox(height: 4),
|
||||
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
|
||||
Text(meal['label']!, style: TextStyle(fontSize: 12, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
|
||||
]),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isSelected ? const Color(0xFF635BFF) : const Color(0xFFF5F3FF),
|
||||
foregroundColor: isSelected ? Colors.white : const Color(0xFF635BFF),
|
||||
backgroundColor: isSelected ? const Color(0xFF8B9CF7) : const Color(0xFFF0F2FF),
|
||||
foregroundColor: isSelected ? Colors.white : const Color(0xFF8B9CF7),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
@@ -268,10 +268,10 @@ class DietCapturePage extends ConsumerWidget {
|
||||
height: 60,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF635BFF)),
|
||||
child: const CircularProgressIndicator(strokeWidth: 3, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('AI 正在识别食物...', style: TextStyle(fontSize: 16, color: Color(0xFF666666))),
|
||||
@@ -286,7 +286,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
),
|
||||
child: Column(children: [
|
||||
Padding(
|
||||
@@ -297,7 +297,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
const Text('识别结果', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, size: 20, color: Color(0xFF635BFF)),
|
||||
icon: const Icon(Icons.add, size: 20, color: Color(0xFF8B9CF7)),
|
||||
onPressed: () => ref.read(dietProvider.notifier).addFood(),
|
||||
),
|
||||
]),
|
||||
@@ -312,14 +312,14 @@ class DietCapturePage extends ConsumerWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: food.selected ? const Color(0xFFF5F3FF) : const Color(0xFFF5F5F5),
|
||||
color: food.selected ? const Color(0xFFF0F2FF) : const Color(0xFFF5F5F5),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(children: [
|
||||
Checkbox(
|
||||
value: food.selected,
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).toggleFood(food.id),
|
||||
activeColor: const Color(0xFF635BFF),
|
||||
activeColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
@@ -339,7 +339,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
controller: TextEditingController(text: food.calories.toString()),
|
||||
keyboardType: TextInputType.number,
|
||||
onChanged: (v) => ref.read(dietProvider.notifier).updateFoodCalories(food.id, int.tryParse(v) ?? 0),
|
||||
style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF)),
|
||||
style: TextStyle(fontSize: 12, color: const Color(0xFF8B9CF7)),
|
||||
),
|
||||
),
|
||||
const Text('kcal', style: TextStyle(fontSize: 12, color: Color(0xFF999999))),
|
||||
@@ -358,7 +358,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Row(children: [
|
||||
@@ -383,7 +383,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEFEFF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
),
|
||||
child: Column(children: [
|
||||
const Text('🥗 健康评分', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
@@ -417,7 +417,7 @@ class DietCapturePage extends ConsumerWidget {
|
||||
switch (score) {
|
||||
case 1: return const Color(0xFFE53935);
|
||||
case 2: return const Color(0xFFF9A825);
|
||||
case 3: return const Color(0xFF635BFF);
|
||||
case 3: return const Color(0xFF8B9CF7);
|
||||
case 4: return const Color(0xFF43A047);
|
||||
case 5: return const Color(0xFF00C853);
|
||||
default: return Colors.grey[400]!;
|
||||
@@ -431,13 +431,13 @@ class DietCapturePage extends ConsumerWidget {
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('饮食记录已保存 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
backgroundColor: Color(0xFF8B9CF7),
|
||||
));
|
||||
popRoute(ref);
|
||||
},
|
||||
child: const Text('保存记录'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import 'dart:io';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
@@ -17,18 +17,23 @@ class HomePage extends ConsumerStatefulWidget {
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
bool _taskCardsExpanded = true;
|
||||
String? _pickedImagePath;
|
||||
final Set<ActiveAgent> _welcomedAgents = {};
|
||||
|
||||
@override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); }
|
||||
@override void initState() { super.initState(); }
|
||||
@override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); }
|
||||
|
||||
void _onScroll() {}
|
||||
|
||||
void _sendMessage() {
|
||||
final text = _textCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
final imagePath = _pickedImagePath;
|
||||
if (text.isEmpty && imagePath == null) return;
|
||||
_textCtrl.clear();
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
setState(() => _pickedImagePath = null);
|
||||
if (imagePath != null) {
|
||||
ref.read(chatProvider.notifier).sendImage(imagePath, text);
|
||||
} else {
|
||||
ref.read(chatProvider.notifier).sendMessage(text);
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@@ -37,29 +42,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
final user = auth.user;
|
||||
final selectedAgent = ref.watch(selectedAgentProvider);
|
||||
|
||||
ref.listen(cameraActionProvider, (prev, next) {
|
||||
if (next == 'camera') {
|
||||
_pickImage(ImageSource.camera);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
} else if (next == 'gallery') {
|
||||
_pickImage(ImageSource.gallery);
|
||||
ref.read(cameraActionProvider.notifier).clear();
|
||||
}
|
||||
});
|
||||
|
||||
return Scaffold(
|
||||
drawer: const HealthDrawer(),
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
body: SafeArea(
|
||||
bottom: false,
|
||||
child: Column(children: [
|
||||
// ── 顶部栏 ──
|
||||
_buildHeader(user),
|
||||
|
||||
// ── 今日任务(可折叠) ──
|
||||
_buildTaskCardsArea(),
|
||||
|
||||
// ── 聊天区域(弹性填充剩余空间) ──
|
||||
// ── 聊天区域(今日任务已移入对话流第一条消息) ──
|
||||
Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)),
|
||||
|
||||
// ── 智能体选择器(常驻显示) ──
|
||||
_buildAgentBar(selectedAgent),
|
||||
|
||||
// ── 选中智能体的操作面板 ──
|
||||
if (selectedAgent != null) _buildAgentPanel(context, selectedAgent),
|
||||
|
||||
// ── 输入框 ──
|
||||
_buildInputBar(context),
|
||||
// ── 底部合并区:智能体栏 + 操作面板 + 输入框(固定高度) ──
|
||||
_buildBottomBar(context, selectedAgent),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -73,11 +78,11 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
child: Row(children: [
|
||||
Builder(builder: (ctx) => GestureDetector(
|
||||
onTap: () => Scaffold.of(ctx).openDrawer(),
|
||||
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF635BFF)) : null),
|
||||
child: CircleAvatar(radius: 20, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 24, color: Color(0xFF8B9CF7)) : null),
|
||||
)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF635BFF)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
|
||||
Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 16, color: const Color(0xFF8B9CF7)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 13, fontWeight: FontWeight.w500, color: Colors.grey[600]))]),
|
||||
const SizedBox(height: 2),
|
||||
Text('${_getGreeting()},${user?.name ?? '张三'}!', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))),
|
||||
])),
|
||||
@@ -94,98 +99,6 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return '晚上好';
|
||||
}
|
||||
|
||||
// ═════════════════════ 今日任务(可折叠/展开) ═════════════════════
|
||||
|
||||
Widget _buildTaskCardsArea() {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
if (_taskCardsExpanded) {
|
||||
return latestHealth.when(
|
||||
data: (data) => _taskCardContent(data),
|
||||
loading: () => _taskCardContent({}),
|
||||
error: (_, __) => _taskCardContent({}),
|
||||
);
|
||||
}
|
||||
|
||||
// 折叠状态:只显示一行可点击的标题栏
|
||||
return GestureDetector(
|
||||
onTap: () => setState(() => _taskCardsExpanded = true),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||
child: Row(children: [
|
||||
Icon(Icons.assignment_turned_in_outlined, size: 18, color: const Color(0xFF635BFF)),
|
||||
const SizedBox(width: 8),
|
||||
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
Text('点击展开', style: TextStyle(fontSize: 12, color: const Color(0xFF635BFF))),
|
||||
Icon(Icons.keyboard_arrow_right, size: 18, color: const Color(0xFF635BFF)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _taskCardContent(Map<String, dynamic> healthData) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 6),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(16), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 6, offset: const Offset(0, 1))]),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
const Text('今日任务', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('收起', style: TextStyle(fontSize: 12, color: const Color(0xFF999999))),
|
||||
Icon(Icons.keyboard_arrow_up, size: 18, color: const Color(0xFF999999)),
|
||||
])),
|
||||
]),
|
||||
const SizedBox(height: 10),
|
||||
..._getTodayTasks(healthData),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
|
||||
return [
|
||||
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck),
|
||||
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
|
||||
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '),
|
||||
..._buildAbnormalRows(healthData),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
|
||||
final rows = <Widget>[];
|
||||
final bp = healthData['BloodPressure'];
|
||||
if (bp is Map) { final s = bp['systolic']; if (s is int && s >= 140) rows.add(_taskRow(icon: Icons.warning_amber_rounded, label: '血压 $s/${bp['diastolic'] ?? '--'} 偏高', status: 'warning', onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}))); }
|
||||
return rows;
|
||||
}
|
||||
|
||||
Widget _taskRow({required IconData icon, required String label, required String status, VoidCallback? onTap}) {
|
||||
final colors = {'done': const Color(0xFF43A047), 'warning': const Color(0xFFFF9800), 'pending': const Color(0xFF9E9E9E)};
|
||||
final icons = {'done': Icons.check_circle, 'warning': Icons.warning, 'pending': Icons.circle_outlined};
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Row(children: [
|
||||
Container(width: 30, height: 30, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 15, color: const Color(0xFF635BFF))),
|
||||
const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 13, color: Color(0xFF333333)))),
|
||||
Icon(icons[status], size: 18, color: colors[status] ?? Colors.grey),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _handleMedicationCheck() async {
|
||||
await ref.read(medicationServiceProvider).confirm('');
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF)));
|
||||
}
|
||||
|
||||
// ═════════════════════ 智能体选择条(常驻) ═════════════════════
|
||||
|
||||
static final _agentDefs = [
|
||||
@@ -199,33 +112,39 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
|
||||
Widget _buildAgentBar(ActiveAgent? selected) {
|
||||
return Container(
|
||||
height: 44,
|
||||
height: 36,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
child: ListView.separated(
|
||||
scrollDirection: Axis.horizontal,
|
||||
itemCount: _agentDefs.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(width: 6),
|
||||
separatorBuilder: (_, i) => const SizedBox(width: 6),
|
||||
itemBuilder: (_, i) {
|
||||
final (agent, label, icon) = _agentDefs[i];
|
||||
final isActive = selected == agent;
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
final notifier = ref.read(selectedAgentProvider.notifier);
|
||||
notifier.select(isActive ? null : agent);
|
||||
// 切换智能体时清空聊天
|
||||
if (!isActive) ref.read(chatProvider.notifier).setAgent(agent);
|
||||
if (isActive) {
|
||||
notifier.select(null);
|
||||
} else {
|
||||
notifier.select(agent);
|
||||
ref.read(chatProvider.notifier).setAgent(agent);
|
||||
if (_welcomedAgents.add(agent)) {
|
||||
ref.read(chatProvider.notifier).insertAgentWelcome(agent);
|
||||
}
|
||||
}
|
||||
},
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: isActive ? const Color(0xFF635BFF) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: isActive ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
|
||||
color: isActive ? const Color(0xFF8B9CF7) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: isActive ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 14, color: isActive ? Colors.white : const Color(0xFF666666)),
|
||||
const SizedBox(width: 4),
|
||||
Text(label, style: TextStyle(fontSize: 12, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
|
||||
Icon(icon, size: 13, color: isActive ? Colors.white : const Color(0xFF666666)),
|
||||
const SizedBox(width: 3),
|
||||
Text(label, style: TextStyle(fontSize: 11, fontWeight: isActive ? FontWeight.w600 : FontWeight.w500, color: isActive ? Colors.white : const Color(0xFF666666))),
|
||||
]),
|
||||
),
|
||||
);
|
||||
@@ -234,68 +153,67 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
// ═════════════════════ 智能体操作面板(选中后显示) ═════════════════════
|
||||
// ═════════════════════ 底部合并区:智能体栏 + 操作面板 + 输入框 ═════════════════════
|
||||
|
||||
Widget _buildAgentPanel(BuildContext context, ActiveAgent agent) {
|
||||
final titles = {ActiveAgent.consultation: 'AI 问诊', ActiveAgent.health: '记数据', ActiveAgent.diet: '拍饮食', ActiveAgent.medication: '药管家', ActiveAgent.report: '看报告', ActiveAgent.exercise: '运动计划'};
|
||||
final tips = {ActiveAgent.consultation: '或直接对我说你的症状', ActiveAgent.health: '或直接对我说:"血压 135/85"', ActiveAgent.diet: '或直接对我说:"中午吃了牛肉面"', ActiveAgent.medication: '或直接对我说:"医生让我吃阿托伐他汀 20mg"', ActiveAgent.report: '或直接上传报告图片', ActiveAgent.exercise: '或直接对我说:"每周一三五散步 30 分钟"'};
|
||||
Widget _buildBottomBar(BuildContext context, ActiveAgent? selectedAgent) {
|
||||
return Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
// 智能体胶囊栏(常驻,高度36)
|
||||
_buildAgentBar(selectedAgent),
|
||||
|
||||
// 图片预览(有选中图片时显示)
|
||||
if (_pickedImagePath != null) _buildImagePreview(),
|
||||
|
||||
// 输入框
|
||||
_buildCompactInputBar(context),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildImagePreview() {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Row(children: [
|
||||
Text(titles[agent] ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(child: Text(tips[agent] ?? '', style: TextStyle(fontSize: 11, color: Colors.grey[500]))),
|
||||
GestureDetector(onTap: () => ref.read(selectedAgentProvider.notifier).select(null), child: Icon(Icons.close, size: 18, color: Colors.grey[400])),
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
|
||||
decoration: const BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
Stack(children: [
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.file(File(_pickedImagePath!), width: 60, height: 60, fit: BoxFit.cover),
|
||||
),
|
||||
Positioned(top: -4, right: -4, child: GestureDetector(
|
||||
onTap: () => setState(() => _pickedImagePath = null),
|
||||
child: Container(width: 20, height: 20, decoration: const BoxDecoration(color: Color(0xFF333333), shape: BoxShape.circle), child: const Icon(Icons.close, size: 14, color: Colors.white)),
|
||||
)),
|
||||
]),
|
||||
const SizedBox(height: 8),
|
||||
SingleChildScrollView(scrollDirection: Axis.horizontal, child: Row(children: _getAgentButtons(agent))),
|
||||
]));
|
||||
}
|
||||
|
||||
List<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||
switch (agent) {
|
||||
case ActiveAgent.health: return [_agentBtn('录入血压', Icons.favorite), _agentBtn('录入血糖', Icons.bloodtype), _agentBtn('录入心率', Icons.monitor_heart), _agentBtn('录入血氧', Icons.air), _agentBtn('录入体重', Icons.monitor_weight)];
|
||||
case ActiveAgent.diet: return [_agentBtn('拍照识别', Icons.camera_alt), _agentBtn('上传照片', Icons.photo_library)];
|
||||
case ActiveAgent.medication: return [_agentBtn('用药管理', Icons.medication), _agentBtn('用药提醒', Icons.alarm)];
|
||||
case ActiveAgent.consultation: return [_agentBtn('找医生', Icons.person_search)];
|
||||
case ActiveAgent.exercise: return [_agentBtn('本周计划', Icons.calendar_view_week), _agentBtn('新建计划', Icons.add_circle_outline)];
|
||||
default: return [];
|
||||
}
|
||||
}
|
||||
|
||||
Widget _agentBtn(String label, IconData icon) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => _onAgentAction(label),
|
||||
icon: Icon(icon, size: 14),
|
||||
label: Text(label, style: const TextStyle(fontSize: 12)),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 12)),
|
||||
),
|
||||
const Spacer(),
|
||||
Text('点击发送上传图片', style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
void _onAgentAction(String label) {
|
||||
switch (label) {
|
||||
case '拍照识别': case '上传照片': pushRoute(ref, 'dietCapture');
|
||||
case '录入血压': _textCtrl.text = '血压 ';
|
||||
case '录入血糖': _textCtrl.text = '血糖 ';
|
||||
case '录入心率': _textCtrl.text = '心率 ';
|
||||
case '录入血氧': _textCtrl.text = '血氧 ';
|
||||
case '录入体重': _textCtrl.text = '体重 ';
|
||||
case '用药管理': pushRoute(ref, 'medications');
|
||||
case '找医生': pushRoute(ref, 'doctors');
|
||||
case '本周计划': case '新建计划': pushRoute(ref, 'exercisePlan');
|
||||
}
|
||||
Widget _buildCompactInputBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||
Expanded(child: TextField(
|
||||
controller: _textCtrl,
|
||||
style: const TextStyle(fontSize: 15),
|
||||
decoration: const InputDecoration(hintText: '输入你想说的...', hintStyle: TextStyle(fontSize: 15, color: Color(0xFFBBBBBB)), contentPadding: EdgeInsets.symmetric(horizontal: 10, vertical: 10), border: InputBorder.none),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
)),
|
||||
IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF8B9CF7)), onPressed: _sendMessage),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; _textCtrl.text = '[图片已上传]'; if (mounted) setState(() {}); }
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
setState(() => _pickedImagePath = picked.path);
|
||||
}
|
||||
}
|
||||
|
||||
void _showAttachmentPicker(BuildContext context) {
|
||||
@@ -306,15 +224,4 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
])));
|
||||
}
|
||||
|
||||
Widget _buildInputBar(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(color: Colors.white, border: Border(top: BorderSide(color: const Color(0xFFEEEEEE)))),
|
||||
child: Row(children: [
|
||||
IconButton(icon: const Icon(Icons.attach_file, size: 22, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)),
|
||||
Expanded(child: TextField(controller: _textCtrl, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 10), border: InputBorder.none, isDense: true), onSubmitted: (_) => _sendMessage())),
|
||||
IconButton(icon: const Icon(Icons.send, size: 22, color: Color(0xFF635BFF)), onPressed: _sendMessage),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,97 +1,528 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
|
||||
class _MedicationItem {
|
||||
String name = '';
|
||||
String dosage = '';
|
||||
String frequency = '每日1次';
|
||||
List<TimeOfDay> times = [const TimeOfDay(hour: 8, minute: 0)];
|
||||
DateTime startDate = DateTime.now();
|
||||
DateTime? endDate;
|
||||
int weekday = 1;
|
||||
}
|
||||
|
||||
const _frequencies = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
|
||||
const _weekdays = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
|
||||
class MedicationEditPage extends ConsumerStatefulWidget {
|
||||
final String? medicationId;
|
||||
const MedicationEditPage({super.key, this.medicationId});
|
||||
|
||||
@override ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
@override
|
||||
ConsumerState<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
}
|
||||
|
||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片');
|
||||
final _dosageCtrl = TextEditingController(text: '100mg');
|
||||
String _frequency = '每日1次';
|
||||
String _time = '08:00';
|
||||
DateTime _startDate = DateTime.now();
|
||||
String _duration = '长期服用';
|
||||
final _items = <_MedicationItem>[];
|
||||
final _nameCtrls = <TextEditingController>[];
|
||||
final _doseCtrls = <TextEditingController>[];
|
||||
|
||||
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); super.dispose(); }
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_addItem();
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in _nameCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
for (final c in _doseCtrls) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _addItem() {
|
||||
setState(() {
|
||||
_items.add(_MedicationItem());
|
||||
_nameCtrls.add(TextEditingController());
|
||||
_doseCtrls.add(TextEditingController());
|
||||
});
|
||||
}
|
||||
|
||||
void _removeItem(int index) {
|
||||
setState(() {
|
||||
_nameCtrls[index].dispose();
|
||||
_doseCtrls[index].dispose();
|
||||
_nameCtrls.removeAt(index);
|
||||
_doseCtrls.removeAt(index);
|
||||
_items.removeAt(index);
|
||||
});
|
||||
}
|
||||
|
||||
void _onSave() async {
|
||||
for (int i = 0; i < _items.length; i++) {
|
||||
_items[i].name = _nameCtrls[i].text.trim();
|
||||
_items[i].dosage = _doseCtrls[i].text.trim();
|
||||
}
|
||||
final allValid = _items.every(
|
||||
(item) => item.name.isNotEmpty && item.dosage.isNotEmpty,
|
||||
);
|
||||
if (!allValid) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('请填写所有药品的名称和剂量')),
|
||||
);
|
||||
return;
|
||||
}
|
||||
final service = ref.read(medicationServiceProvider);
|
||||
try {
|
||||
for (final item in _items) {
|
||||
final timesStr = item.frequency == '按需服用'
|
||||
? []
|
||||
: item.times.map((t) => t.format(context)).toList();
|
||||
await service.create({
|
||||
'name': item.name,
|
||||
'dosage': item.dosage,
|
||||
'frequency': 'Daily',
|
||||
'timeOfDay': timesStr,
|
||||
'startDate': item.startDate.toIso8601String().split('T')[0],
|
||||
if (item.endDate != null)
|
||||
'endDate': item.endDate!.toIso8601String().split('T')[0],
|
||||
'source': 'Manual',
|
||||
});
|
||||
}
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('已添加 ${_items.length} 种药品'),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
);
|
||||
ref.invalidate(medicationListProvider);
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
popRoute(ref);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('保存失败:$e'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
// 仍然返回上一页,避免卡在黑屏
|
||||
popRoute(ref);
|
||||
}
|
||||
}
|
||||
|
||||
int _timeCount(String frequency) {
|
||||
switch (frequency) {
|
||||
case '每日1次':
|
||||
return 1;
|
||||
case '每日2次':
|
||||
return 2;
|
||||
case '每日3次':
|
||||
return 3;
|
||||
case '每周1次':
|
||||
return 1;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)),
|
||||
title: const Text('编辑用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.chevron_left),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text(
|
||||
'添加用药',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1A1A1A),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('保存成功 ✅'), backgroundColor: Color(0xFF635BFF)));
|
||||
Navigator.pop(context);
|
||||
},
|
||||
child: const Text('保存', style: TextStyle(color: Color(0xFF635BFF), fontWeight: FontWeight.w600)),
|
||||
onPressed: _onSave,
|
||||
child: const Text(
|
||||
'保存',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF8B9CF7),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('药品信息', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 12),
|
||||
TextField(controller: _nameCtrl, decoration: InputDecoration(hintText: '请输入药品名称', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||
const SizedBox(height: 16),
|
||||
TextField(controller: _dosageCtrl, decoration: InputDecoration(hintText: '如:100mg', filled: true, fillColor: Colors.grey[50], border: OutlineInputBorder(borderRadius: BorderRadius.circular(12), borderSide: BorderSide.none))),
|
||||
const SizedBox(height: 24),
|
||||
const Text('服用设置', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 12),
|
||||
GestureDetector(onTap: _pickFrequency, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_frequency, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(onTap: _pickTime, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_time, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.access_time, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(onTap: _pickDate, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text('${_startDate.year}-${_startDate.month.toString().padLeft(2, '0')}-${_startDate.day.toString().padLeft(2, '0')}', style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.calendar_today, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||
const SizedBox(height: 16),
|
||||
GestureDetector(onTap: _pickDuration, child: Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(_duration, style: const TextStyle(fontSize: 15)), const Spacer(), const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E))]))),
|
||||
const SizedBox(height: 32),
|
||||
SizedBox(width: double.infinity, height: 50, child: ElevatedButton(
|
||||
onPressed: () {},
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF635BFF), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(25))),
|
||||
child: const Text('新增用药', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
)),
|
||||
const SizedBox(height: 20),
|
||||
]),
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
...List.generate(_items.length, (i) => _buildCard(i)),
|
||||
const SizedBox(height: 12),
|
||||
_buildAddButton(),
|
||||
const SizedBox(height: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickFrequency() async {
|
||||
final options = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用'];
|
||||
Widget _buildCard(int index) {
|
||||
final item = _items[index];
|
||||
final count = _timeCount(item.frequency);
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: const Color(0xFFEEEEEE)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
'药品 ${index + 1}',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: Color(0xFF8B9CF7),
|
||||
),
|
||||
),
|
||||
if (_items.length > 1)
|
||||
GestureDetector(
|
||||
onTap: () => _removeItem(index),
|
||||
child: const Icon(Icons.close, size: 18, color: Color(0xFFBDBDBD)),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Divider(height: 1, color: const Color(0xFFF0F0F0)),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Name
|
||||
_buildLabel('药品名称'),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _nameCtrls[index],
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: _inputDecoration('请输入药品名称'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Dosage
|
||||
_buildLabel('剂量'),
|
||||
const SizedBox(height: 4),
|
||||
TextField(
|
||||
controller: _doseCtrls[index],
|
||||
style: const TextStyle(fontSize: 14),
|
||||
decoration: _inputDecoration('如:100mg'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Frequency
|
||||
_buildLabel('服用频率'),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _pickFrequency(index),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
color: const Color(0xFFFAFAFA),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(item.frequency, style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const Spacer(),
|
||||
const Icon(Icons.keyboard_arrow_down, size: 20, color: Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// Times (dynamic)
|
||||
if (count > 0) ...[
|
||||
_buildLabel('服药时间'),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: List.generate(count, (t) => _buildTimePicker(index, t)),
|
||||
),
|
||||
if (item.frequency == '每周1次') ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildLabel('选择星期'),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _pickWeekday(index),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
color: const Color(0xFFFAFAFA),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_weekdays[item.weekday - 1], style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(width: 4),
|
||||
const Icon(Icons.keyboard_arrow_down, size: 18, color: Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
|
||||
// Start date
|
||||
_buildLabel('开始日期'),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _pickDate(index, isStart: true),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
color: const Color(0xFFFAFAFA),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${item.startDate.year}-${item.startDate.month.toString().padLeft(2, '0')}-${item.startDate.day.toString().padLeft(2, '0')}',
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
|
||||
),
|
||||
const Spacer(),
|
||||
const Icon(Icons.calendar_today, size: 18, color: Color(0xFF9E9E9E)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// End date (optional)
|
||||
_buildLabel('结束日期(可选)'),
|
||||
const SizedBox(height: 4),
|
||||
GestureDetector(
|
||||
onTap: () => _pickDate(index, isStart: false),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
color: const Color(0xFFFAFAFA),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
item.endDate != null
|
||||
? '${item.endDate!.year}-${item.endDate!.month.toString().padLeft(2, '0')}-${item.endDate!.day.toString().padLeft(2, '0')}'
|
||||
: '不设置',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: item.endDate != null ? const Color(0xFF1A1A1A) : const Color(0xFFBDBDBD),
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: item.endDate != null ? () => setState(() => item.endDate = null) : null,
|
||||
child: Icon(
|
||||
item.endDate != null ? Icons.close : Icons.calendar_today,
|
||||
size: 18,
|
||||
color: const Color(0xFF9E9E9E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLabel(String text) {
|
||||
return Text(
|
||||
text,
|
||||
style: const TextStyle(fontSize: 12, color: Color(0xFF757575)),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimePicker(int itemIndex, int timeIndex) {
|
||||
final item = _items[itemIndex];
|
||||
final time = item.times[timeIndex];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => _pickTime(itemIndex, timeIndex),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFE0E0E0)),
|
||||
color: const Color(0xFFFAFAFA),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.access_time, size: 16, color: Color(0xFF8B9CF7)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
time.format(context),
|
||||
style: const TextStyle(fontSize: 14, color: Color(0xFF1A1A1A)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildAddButton() {
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: _addItem,
|
||||
icon: const Icon(Icons.add, size: 18),
|
||||
label: const Text('添加', style: TextStyle(fontSize: 14)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF8B9CF7),
|
||||
side: const BorderSide(color: Color(0xFFD0D5FC)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
backgroundColor: const Color(0xFFF0F2FF),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
InputDecoration _inputDecoration(String hint) {
|
||||
return InputDecoration(
|
||||
hintText: hint,
|
||||
hintStyle: const TextStyle(color: Color(0xFFBDBDBD), fontSize: 14),
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFFAFAFA),
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
isDense: true,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFFE0E0E0)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderSide: const BorderSide(color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _pickFrequency(int index) async {
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: _frequencies
|
||||
.map((f) => ListTile(
|
||||
title: Text(f),
|
||||
onTap: () => Navigator.pop(ctx, f),
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected != null && mounted) setState(() => _frequency = selected);
|
||||
if (selected != null && mounted) {
|
||||
setState(() {
|
||||
final item = _items[index];
|
||||
item.frequency = selected;
|
||||
final newCount = _timeCount(selected);
|
||||
if (newCount > 0 && item.times.length != newCount) {
|
||||
item.times = List.generate(
|
||||
newCount,
|
||||
(i) => TimeOfDay(hour: 8 + i * 4, minute: 0),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
void _pickTime() async {
|
||||
final time = await showTimePicker(context: context, initialTime: TimeOfDay.now());
|
||||
if (time != null && mounted) setState(() => _time = time.format(context));
|
||||
}
|
||||
|
||||
void _pickDate() async {
|
||||
final date = await showDatePicker(context: context, firstDate: DateTime(2020), lastDate: DateTime(2030), initialDate: _startDate);
|
||||
if (date != null && mounted) setState(() => _startDate = date);
|
||||
}
|
||||
|
||||
void _pickDuration() async {
|
||||
final options = ['长期服用', '7天', '14天', '30天', '90天'];
|
||||
final selected = await showModalBottomSheet<String>(
|
||||
void _pickWeekday(int index) async {
|
||||
final item = _items[index];
|
||||
final selected = await showModalBottomSheet<int>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||
builder: (ctx) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: List.generate(7, (i) {
|
||||
return ListTile(
|
||||
title: Text(_weekdays[i]),
|
||||
selected: item.weekday == i + 1,
|
||||
onTap: () => Navigator.pop(ctx, i + 1),
|
||||
);
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
if (selected != null && mounted) setState(() => _duration = selected);
|
||||
if (selected != null && mounted) {
|
||||
setState(() => _items[index].weekday = selected);
|
||||
}
|
||||
}
|
||||
|
||||
void _pickTime(int itemIndex, int timeIndex) async {
|
||||
final item = _items[itemIndex];
|
||||
final time = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: item.times[timeIndex],
|
||||
);
|
||||
if (time != null && mounted) {
|
||||
setState(() => item.times[timeIndex] = time);
|
||||
}
|
||||
}
|
||||
|
||||
void _pickDate(int index, {required bool isStart}) async {
|
||||
final item = _items[index];
|
||||
final initial = isStart ? item.startDate : (item.endDate ?? DateTime.now());
|
||||
final date = await showDatePicker(
|
||||
context: context,
|
||||
firstDate: DateTime(2020),
|
||||
lastDate: DateTime(2030),
|
||||
initialDate: initial,
|
||||
);
|
||||
if (date != null && mounted) {
|
||||
setState(() {
|
||||
if (isStart) {
|
||||
item.startDate = date;
|
||||
} else {
|
||||
item.endDate = date;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class MedicationListPage extends ConsumerWidget {
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final meds = ref.watch(medicationListProvider);
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
@@ -19,9 +19,9 @@ class MedicationListPage extends ConsumerWidget {
|
||||
TextButton(
|
||||
onPressed: () => pushRoute(ref, 'medicationEdit'),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF635BFF)),
|
||||
const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF8B9CF7)),
|
||||
const SizedBox(width: 4),
|
||||
const Text('添加新药', style: TextStyle(color: Color(0xFF635BFF), fontSize: 14)),
|
||||
const Text('添加新药', style: TextStyle(color: Color(0xFF8B9CF7), fontSize: 14)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
@@ -47,8 +47,8 @@ class MedicationListPage extends ConsumerWidget {
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
|
||||
error: (_, __) => _empty(context),
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
error: (_, e) => _empty(context),
|
||||
)),
|
||||
_buildReminderBar(),
|
||||
]),
|
||||
@@ -65,11 +65,11 @@ class MedicationListPage extends ConsumerWidget {
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFF635BFF).withAlpha(50)),
|
||||
border: Border.all(color: const Color(0xFF8B9CF7).withAlpha(50)),
|
||||
),
|
||||
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF635BFF)),
|
||||
child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
@@ -102,9 +102,9 @@ class _TabChip extends StatelessWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? const Color(0xFF635BFF) : Colors.white,
|
||||
color: active ? const Color(0xFF8B9CF7) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: active ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)),
|
||||
border: Border.all(color: active ? const Color(0xFF8B9CF7) : const Color(0xFFE0E0E0)),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
@@ -130,14 +130,14 @@ class _MedicationCard extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))),
|
||||
|
||||
@@ -10,7 +10,7 @@ class ProfileDetailPage extends ConsumerWidget {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
@@ -22,23 +22,23 @@ class ProfileDetailPage extends ConsumerWidget {
|
||||
]),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF635BFF), side: const BorderSide(color: Color(0xFF635BFF)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(children: [_buildUserCard(), const SizedBox(height: 16), _buildHealthOverview(latestHealth), const SizedBox(height: 16), _buildHistoryList(), const SizedBox(height: 16), SizedBox(width: double.infinity, height: 48, child: OutlinedButton(onPressed: () => pushRoute(ref, 'settings'), style: OutlinedButton.styleFrom(foregroundColor: const Color(0xFF8B9CF7), side: const BorderSide(color: Color(0xFF8B9CF7)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))), child: const Text('退出档案')))]))),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.person, size: 40, color: Color(0xFF635BFF))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
|
||||
Widget _buildUserCard() => Container(width: double.infinity, padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Row(children: [CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7))), const SizedBox(width: 16), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('张三', style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 4), Text('42岁 · 男 · 175cm · 72kg', style: TextStyle(fontSize: 14, color: Colors.grey[500]))])), Icon(Icons.chevron_right, size: 24, color: Colors.grey[400])]));
|
||||
|
||||
Widget _buildHealthOverview(AsyncValue<Map<String, dynamic>> healthData) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
const Text('健康概览', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
const SizedBox(height: 4),
|
||||
Text('(最近测量)', style: TextStyle(fontSize: 13, color: Colors.grey[500])),
|
||||
const SizedBox(height: 16),
|
||||
healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF)))), error: (_, __) => _buildMetricsEmpty()),
|
||||
healthData.when(data: (data) => _buildMetricsList(data), loading: () => const Center(child: Padding(padding: EdgeInsets.all(24), child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF8B9CF7)))), error: (_, e) => _buildMetricsEmpty()),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -54,11 +54,11 @@ class ProfileDetailPage extends ConsumerWidget {
|
||||
String _formatBP(dynamic bp) { if (bp is Map) { final s = bp['systolic']; final d = bp['diastolic']; if (s != null && d != null) return '$s/$d'; } return '--/--'; }
|
||||
String _formatMetric(dynamic val, String unit) { if (val is Map) { final v = val['value']; if (v != null) return '$v$unit'; } return '-- $unit'; }
|
||||
|
||||
Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 15, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 18, color: Colors.grey[400])])));
|
||||
Widget _metricRow(IconData icon, String label, String value) => InkWell(onTap: () {}, borderRadius: BorderRadius.circular(12), child: Padding(padding: const EdgeInsets.symmetric(vertical: 14), child: Row(children: [Container(width: 40, height: 40, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))), const SizedBox(width: 12), Expanded(child: Text(label, style: const TextStyle(fontSize: 15, color: Color(0xFF333333)))), Text(value, style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Icon(Icons.chevron_right, size: 18, color: Colors.grey[400])])));
|
||||
|
||||
Widget _buildHistoryList() {
|
||||
final items = [{'date': '05-31', 'label': '血压 · 餐前', 'value': '128/82', 'status': 'normal'}, {'date': '05-30', 'label': '血压 · 餐后', 'value': '135/85', 'status': 'warning'}, {'date': '05-29', 'label': '血压 · 餐前', 'value': '122/78', 'status': 'normal'}, {'date': '05-28', 'label': '血压 · 餐前', 'value': '118/76', 'status': 'normal'}, {'date': '05-27', 'label': '血糖 · 空腹', 'value': '5.6', 'status': 'normal'}, {'date': '05-26', 'label': '血压 · 餐前', 'value': '120/80', 'status': 'normal'}];
|
||||
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF)))])), ...items.map((item) => _historyItem(item)).toList()]));
|
||||
return Container(decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]), child: Column(children: [Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), child: Row(children: [const Text('历史记录', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Text('查看更多', style: TextStyle(fontSize: 13, color: const Color(0xFF8B9CF7)))])), ...items.map(_historyItem)]));
|
||||
}
|
||||
|
||||
Widget _historyItem(Map<String, dynamic> item) {
|
||||
|
||||
@@ -11,17 +11,17 @@ class ProfilePage extends ConsumerWidget {
|
||||
final user = auth.user;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 20), child: Column(children: [
|
||||
Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Column(children: [
|
||||
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),]),
|
||||
const SizedBox(height: 20),
|
||||
Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profileEdit'),
|
||||
onTap: () => pushRoute(ref, 'editProfile'),
|
||||
child: Stack(children: [
|
||||
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF635BFF)) : null),
|
||||
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
|
||||
CircleAvatar(radius: 32, backgroundColor: const Color(0xFFF0F2FF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 40, color: Color(0xFF8B9CF7)) : null),
|
||||
Positioned(right: 0, bottom: 0, child: Container(width: 22, height: 22, decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(11), border: Border.all(color: Colors.white, width: 2)), child: const Icon(Icons.edit, size: 12, color: Colors.white))),
|
||||
]),
|
||||
),
|
||||
const SizedBox(width: 16),
|
||||
@@ -34,9 +34,9 @@ class ProfilePage extends ConsumerWidget {
|
||||
]),
|
||||
])),
|
||||
const SizedBox(height: 12),
|
||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'profile')),
|
||||
_MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理', onTap: () => pushRoute(ref, 'devices')),
|
||||
_MenuItem(icon: Icons.favorite_border, title: '就诊收藏', trailing: '3'),
|
||||
_MenuItem(icon: Icons.devices, title: '设备管理'),
|
||||
_MenuItem(icon: Icons.people_outline, title: '家人关怀'),
|
||||
_MenuItem(icon: Icons.local_hospital_outlined, title: '医生绑定记录'),
|
||||
_MenuItem(icon: Icons.chat_bubble_outline, title: '意见反馈'),
|
||||
@@ -87,7 +87,7 @@ class _MenuItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||
decoration: BoxDecoration(color: Colors.white),
|
||||
child: Row(children: [
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))),
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
|
||||
const SizedBox(width: 12),
|
||||
Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
||||
if (trailing != null && trailing!.isNotEmpty) ...[const Spacer(), Text(trailing!, style: TextStyle(fontSize: 14, color: Colors.grey[400]))],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../core/navigation_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
|
||||
/// 饮食记录列表
|
||||
@@ -50,7 +51,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
onPressed: () => _createDefaultPlan(ref, context),
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('创建本周计划'),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
body: plan.when(
|
||||
data: (data) {
|
||||
@@ -81,8 +82,8 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
}),
|
||||
]);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))),
|
||||
error: (_, __) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF8B9CF7))),
|
||||
error: (_, e) => _empty(context, '运动计划', '暂无运动计划,点击右下角创建'),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -93,7 +94,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
margin: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: Column(children: [
|
||||
@@ -104,7 +105,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
width: 60,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
color: const Color(0xFF8B9CF7),
|
||||
borderRadius: BorderRadius.circular(30),
|
||||
),
|
||||
child: Center(
|
||||
@@ -122,7 +123,7 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
child: FractionallySizedBox(
|
||||
widthFactor: progress / 100,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(color: const Color(0xFF635BFF), borderRadius: BorderRadius.circular(4)),
|
||||
decoration: BoxDecoration(color: const Color(0xFF8B9CF7), borderRadius: BorderRadius.circular(4)),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -134,30 +135,41 @@ class ExercisePlanPage extends ConsumerWidget {
|
||||
}
|
||||
|
||||
void _createDefaultPlan(WidgetRef ref, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
final items = List.generate(7, (i) => {
|
||||
'dayOfWeek': i,
|
||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||||
'isRestDay': i == 2 || i == 5,
|
||||
});
|
||||
await service.createPlan({
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
));
|
||||
try {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
final items = List.generate(7, (i) => {
|
||||
'dayOfWeek': i,
|
||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||||
'isRestDay': i == 2 || i == 5,
|
||||
});
|
||||
await service.createPlan({
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': items,
|
||||
});
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('运动计划已创建'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
));
|
||||
} catch (e) {
|
||||
// 后端不可用时,直接使用本地 mock 数据
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('已创建本地计划(离线模式)'), backgroundColor: const Color(0xFFFF9800)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _checkIn(WidgetRef ref, String itemId, BuildContext context) async {
|
||||
final service = ref.read(exerciseServiceProvider);
|
||||
await service.checkIn(itemId);
|
||||
ref.invalidate(currentExercisePlanProvider);
|
||||
if (!context.mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('打卡成功 ✅'),
|
||||
backgroundColor: Color(0xFF43A047),
|
||||
@@ -196,21 +208,21 @@ class _ExercisePlanItem extends StatelessWidget {
|
||||
color: isToday ? const Color(0xFFFEFCE8) : Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: isToday ? Border.all(color: const Color(0xFFFCD34D), width: 2) : null,
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(8), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF5F3FF),
|
||||
color: isDone ? const Color(0xFFDCFCE7) : isRest ? const Color(0xFFF3F4F6) : const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: isDone
|
||||
? const Icon(Icons.check, size: 20, color: Color(0xFF43A047))
|
||||
: isRest
|
||||
? const Icon(Icons.coffee, size: 20, color: Color(0xFF999999))
|
||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF635BFF)),
|
||||
: const Icon(Icons.directions_run, size: 20, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
@@ -222,7 +234,7 @@ class _ExercisePlanItem extends StatelessWidget {
|
||||
]),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
isRest ? '休息日,好好休息' : '$exerciseType ${duration}分钟',
|
||||
isRest ? '休息日,好好休息' : '$exerciseType $duration分钟',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[500]),
|
||||
),
|
||||
]),
|
||||
@@ -232,7 +244,7 @@ class _ExercisePlanItem extends StatelessWidget {
|
||||
onPressed: onCheckIn,
|
||||
child: const Text('打卡'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
||||
@@ -254,7 +266,7 @@ class FollowUpListPage extends ConsumerWidget {
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: () => _showAddDialog(context),
|
||||
child: const Icon(Icons.add),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
),
|
||||
body: ListView(children: _mockFollowUps.map((item) => _FollowUpItem(item: item)).toList()),
|
||||
);
|
||||
@@ -281,7 +293,7 @@ class FollowUpListPage extends ConsumerWidget {
|
||||
Navigator.pop(ctx);
|
||||
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(
|
||||
content: Text('复查提醒已添加 ✅'),
|
||||
backgroundColor: Color(0xFF635BFF),
|
||||
backgroundColor: Color(0xFF8B9CF7),
|
||||
));
|
||||
},
|
||||
child: const Text('保存'),
|
||||
@@ -311,7 +323,7 @@ class _FollowUpItem extends StatelessWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
@@ -510,7 +522,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
|
||||
return Container(
|
||||
decoration: isToday ? BoxDecoration(
|
||||
color: const Color(0xFF635BFF),
|
||||
color: const Color(0xFF8B9CF7),
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
) : null,
|
||||
child: Stack(
|
||||
@@ -552,7 +564,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
|
||||
Color _getEventColor(String type) {
|
||||
switch (type) {
|
||||
case 'medication': return const Color(0xFF635BFF);
|
||||
case 'medication': return const Color(0xFF8B9CF7);
|
||||
case 'exercise': return const Color(0xFF43A047);
|
||||
case 'followup': return const Color(0xFFF59E0B);
|
||||
default: return Colors.grey;
|
||||
@@ -561,7 +573,7 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
|
||||
|
||||
Widget _buildLegend() {
|
||||
final items = [
|
||||
{'color': const Color(0xFF635BFF), 'label': '用药提醒'},
|
||||
{'color': const Color(0xFF8B9CF7), 'label': '用药提醒'},
|
||||
{'color': const Color(0xFF43A047), 'label': '运动计划'},
|
||||
{'color': const Color(0xFFF59E0B), 'label': '复查随访'},
|
||||
];
|
||||
@@ -582,8 +594,91 @@ class StaticTextPage extends ConsumerWidget {
|
||||
final String type;
|
||||
const StaticTextPage({super.key, required this.type});
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titles = {'privacy': '隐私政策', 'terms': '服务协议', 'about': '关于'};
|
||||
return Scaffold(appBar: AppBar(title: Text(titles[type] ?? '')), body: const Center(child: Padding(padding: EdgeInsets.all(16), child: Text('内容后期填充', style: TextStyle(color: Color(0xFF999999))))));
|
||||
final titles = {'privacy': '隐私协议', 'terms': '服务协议', 'about': '关于健康管家'};
|
||||
final contents = {
|
||||
'privacy': '''## 隐私政策
|
||||
|
||||
更新日期:2026年1月1日
|
||||
|
||||
### 一、信息收集
|
||||
我们收集以下类型的信息:
|
||||
- 账户信息:手机号、昵称、头像(您主动提供)
|
||||
- 健康数据:血压、心率、血糖、血氧、体重等健康指标记录
|
||||
- 用药信息:药品名称、剂量、服药时间等用药计划数据
|
||||
- 饮食记录:通过拍照或手动录入的饮食数据
|
||||
- 设备信息:设备型号、操作系统版本(用于适配优化)
|
||||
- 日志信息:App 使用情况、崩溃报告
|
||||
|
||||
### 二、信息使用
|
||||
我们使用您的信息用于以下目的:
|
||||
- 提供和改进健康管理服务
|
||||
- AI 健康分析和个性化建议
|
||||
- 用药提醒和复查通知推送
|
||||
- App 功能优化和问题修复
|
||||
|
||||
### 三、信息保护
|
||||
- 所有健康数据均采用 HTTPS 加密传输
|
||||
- 数据存储于安全服务器,采用行业标准的加密措施
|
||||
- 我们不会向任何第三方出售、出租或共享您的个人健康数据
|
||||
- 医生仅可查看其签约患者的数据,且需经过您的授权
|
||||
|
||||
### 四、信息保留
|
||||
- 对话记录保留 30 天后自动删除
|
||||
- 您可以随时删除自己的健康数据和对话记录
|
||||
- 账号注销后,所有数据将在 7 天内永久删除
|
||||
|
||||
### 五、您的权利
|
||||
- 查看和导出您的个人数据
|
||||
- 修改不准确的个人信息
|
||||
- 删除不需要的数据
|
||||
- 注销账号并清除所有数据
|
||||
- 关闭推送通知
|
||||
|
||||
### 六、联系我们
|
||||
如有任何关于隐私的问题,请联系:
|
||||
邮箱:privacy@healthbutler.com
|
||||
电话:400-xxx-xxxx''',
|
||||
'about': '''## 关于健康管家
|
||||
|
||||
版本:v1.0.0 (Build 20260101)
|
||||
|
||||
### 产品介绍
|
||||
健康管家是一款面向心脏术后康复患者的私人 AI 健康管理应用。以对话为核心交互方式,患者可以通过自然语言记录健康数据、获取饮食运动建议、管理用药、解读检查报告。
|
||||
|
||||
### 核心功能
|
||||
- AI 智能问诊:基于大语言模型的健康咨询服务
|
||||
- 健康数据管理:血压、心率、血糖、血氧、体重的记录与趋势分析
|
||||
- 智能用药管理:AI 解析处方,自动生成用药计划和提醒
|
||||
- 饮食识别分析:拍照即可识别食物种类、估算热量营养素
|
||||
- 报告智能解读:上传检查报告,AI 自动提取指标并预解读
|
||||
- 运动计划管理:制定和追踪每日运动目标
|
||||
- 在线医生问诊:与签约医生进行远程咨询
|
||||
|
||||
### 开发团队
|
||||
由专业医疗团队与 AI 技术团队联合打造。
|
||||
|
||||
### 技术支持
|
||||
如遇到问题或有建议,请通过以下方式联系我们:
|
||||
- 在线客服:App 内「设置」→「意见反馈」
|
||||
- 客服热线:400-xxx-xxxx(工作日 9:00-18:00)
|
||||
|
||||
### 版权声明
|
||||
© 2025-2026 健康管家团队。保留所有权利。
|
||||
本软件受中华人民共和国著作权法保护。''',
|
||||
};
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
|
||||
title: Text(titles[type] ?? '', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(contents[type] ?? '内容加载中...', style: const TextStyle(fontSize: 14, height: 1.8, color: Color(0xFF333333))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,16 @@ class AiAnalysisPage extends ConsumerWidget {
|
||||
|
||||
Widget _buildTitle() {
|
||||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF635BFF)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF635BFF), fontWeight: FontWeight.w500))])),
|
||||
Container(padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(12)), child: Row(mainAxisSize: MainAxisSize.min, children: [const Icon(Icons.auto_awesome, size: 16, color: Color(0xFF8B9CF7)), const SizedBox(width: 4), const Text('AI预解读', style: TextStyle(fontSize: 13, color: Color(0xFF8B9CF7), fontWeight: FontWeight.w500))])),
|
||||
const SizedBox(width: 4), Text('血常规检查', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)),
|
||||
]);
|
||||
}
|
||||
|
||||
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
|
||||
Widget _buildReportPreview() => Container(width: double.infinity, height: 180, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5)), child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [Icon(Icons.description_outlined, size: 48, color: Colors.grey[400]), const SizedBox(height: 8), Text('检查报告图片', style: TextStyle(fontSize: 14, color: Colors.grey[600]))]));
|
||||
|
||||
Widget _buildIndicators() {
|
||||
final indicators = [{'name': '红细胞 (RBC)', 'value': '4.68', 'unit': '(×10¹²/L)', 'ref': '4.0-5.50', 'status': 'normal'}, {'name': '白细胞 (WBC)', 'value': '6.55', 'unit': '(×10⁹/L)', 'ref': '3.5-9.50', 'status': 'normal'}, {'name': '血红蛋白 (HGB)', 'value': '135', 'unit': '(g/L)', 'ref': '120-175', 'status': 'normal'}, {'name': '血小板 (PLT)', 'value': '235', 'unit': '(×10⁹/L)', 'ref': '125-350', 'status': 'normal'}];
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item)).toList()]);
|
||||
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [const Text('指标详情', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(height: 12), ...indicators.map((item) => _indicatorCard(item))]);
|
||||
}
|
||||
|
||||
Widget _indicatorCard(Map<String, dynamic> item) {
|
||||
@@ -31,11 +31,11 @@ class AiAnalysisPage extends ConsumerWidget {
|
||||
return Container(margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration(color: isNormal ? const Color(0xFFF8FDFB) : const Color(0xFFFFF8F5), borderRadius: BorderRadius.circular(14), border: Border.all(color: isNormal ? const Color(0xFFD4EDDA) : const Color(0xFFFFD7C5))), child: Row(children: [Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(item['name']?.toString() ?? '', style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(height: 4), Text('参考范围:${item['ref']?.toString() ?? ''}', style: TextStyle(fontSize: 12, color: Colors.grey[500]))])), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [Text('${item['value']?.toString() ?? ''} ${item['unit']?.toString() ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), const SizedBox(height: 2), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(8)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])]));
|
||||
}
|
||||
|
||||
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 6), const Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF635BFF).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF635BFF))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: const Color(0xFF444444)))]));
|
||||
Widget _buildAiInterpretation() => Container(width: double.infinity, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(16)), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.auto_awesome, size: 18, color: const Color(0xFF8B9CF7)), const SizedBox(width: 6), Text('AI 智能解读', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: const Color(0xFF8B9CF7).withAlpha(15), borderRadius: BorderRadius.circular(10)), child: const Text('已分析', style: TextStyle(fontSize: 11, color: Color(0xFF8B9CF7))))]), const SizedBox(height: 12), const Text('您的血常规检查结果基本正常,各项指标均在参考范围内。红细胞、白细胞、血小板计数均处于健康水平,血红蛋白含量充足,说明您的造血功能和免疫功能良好。建议继续保持良好的生活习惯,定期复查。', style: TextStyle(fontSize: 14, height: 1.6, color: Color(0xFF444444)))]));
|
||||
|
||||
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFEDEBFF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF635BFF))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
|
||||
Widget _buildDoctorAdvice() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [CircleAvatar(radius: 16, backgroundColor: const Color(0xFFF0F2FF), child: const Icon(Icons.local_hospital, size: 16, color: Color(0xFF8B9CF7))), const SizedBox(width: 8), const Text('医生建议', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), Container(width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration(borderRadius: BorderRadius.circular(14), border: Border.all(color: const Color(0xFFEEEEEE))), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_adviceItem('李医生', '心内科', '各项指标正常,继续保持。注意低盐饮食,适当运动。'), const Divider(), _adviceItem('王医生', '全科', '血常规结果理想,无需特殊处理。下次体检可关注血脂指标.')]))]);
|
||||
|
||||
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF5F3FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
|
||||
Widget _adviceItem(String name, String dept, String advice) => Padding(padding: const EdgeInsets.symmetric(vertical: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [CircleAvatar(radius: 14, backgroundColor: const Color(0xFFF0F2FF), child: Text(name[0], style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))), const SizedBox(width: 10), Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Text(name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF333333))), const SizedBox(width: 6), Text(dept, style: TextStyle(fontSize: 12, color: Colors.grey[500]))]), const SizedBox(height: 4), Text(advice, style: TextStyle(fontSize: 13, color: Colors.grey[700], height: 1.4))]))]));
|
||||
|
||||
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动,每周至少150分钟中等强度有氧运动', '保证充足睡眠,每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))]))).toList()]);
|
||||
Widget _buildHealthTips() => Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Row(children: [Icon(Icons.lightbulb_outline, size: 18, color: const Color(0xFFFFB800)), const SizedBox(width: 8), const Text('健康提示', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A)))]), const SizedBox(height: 12), ...['定期进行血常规检查,建议每半年一次', '保持均衡饮食,多吃富含铁和维生素的食物', '适度运动,每周至少150分钟中等强度有氧运动', '保证充足睡眠,每晚7-8小时'].map((tip) => Padding(padding: const EdgeInsets.only(bottom: 8), child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [Container(margin: const EdgeInsets.only(top: 6), width: 6, height: 6, decoration: const BoxDecoration(color: Color(0xFFFFB800), shape: BoxShape.circle)), const SizedBox(width: 10), Expanded(child: Text(tip, style: TextStyle(fontSize: 14, color: Colors.grey[700], height: 1.4)))])))]);
|
||||
}
|
||||
|
||||
@@ -182,7 +182,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
appBar: AppBar(title: const Text('看报告')),
|
||||
body: const Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
CircularProgressIndicator(color: Color(0xFF635BFF)),
|
||||
CircularProgressIndicator(color: Color(0xFF8B9CF7)),
|
||||
SizedBox(height: 16),
|
||||
Text('AI 正在分析报告...'),
|
||||
]),
|
||||
@@ -209,7 +209,7 @@ class ReportListPage extends ConsumerWidget {
|
||||
Widget _buildUploadButton(BuildContext context, WidgetRef ref) {
|
||||
return FloatingActionButton(
|
||||
onPressed: () => _showUploadOptions(context, ref),
|
||||
backgroundColor: const Color(0xFF635BFF),
|
||||
backgroundColor: const Color(0xFF8B9CF7),
|
||||
child: const Icon(Icons.add),
|
||||
);
|
||||
}
|
||||
@@ -266,10 +266,10 @@ class ReportListPage extends ConsumerWidget {
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(60),
|
||||
),
|
||||
child: const Icon(Icons.file_open, size: 48, color: Color(0xFF635BFF)),
|
||||
child: const Icon(Icons.file_open, size: 48, color: Color(0xFF8B9CF7)),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
const Text('暂无检查报告', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w500)),
|
||||
@@ -285,14 +285,14 @@ class ReportListPage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
boxShadow: [BoxShadow(color: const Color(0xFF8B9CF7).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))],
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: _getReportIcon(report.type),
|
||||
@@ -315,13 +315,13 @@ class ReportListPage extends ConsumerWidget {
|
||||
|
||||
Widget _getReportIcon(String type) {
|
||||
final icons = {
|
||||
'血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF635BFF)),
|
||||
'心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF635BFF)),
|
||||
'超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)),
|
||||
'影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF635BFF)),
|
||||
'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF635BFF)),
|
||||
'血液检查': const Icon(Icons.bloodtype, size: 24, color: Color(0xFF8B9CF7)),
|
||||
'心电图': const Icon(Icons.monitor_heart, size: 24, color: Color(0xFF8B9CF7)),
|
||||
'超声检查': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
|
||||
'影像报告': const Icon(Icons.image, size: 24, color: Color(0xFF8B9CF7)),
|
||||
'PDF文档': const Icon(Icons.picture_as_pdf, size: 24, color: Color(0xFF8B9CF7)),
|
||||
};
|
||||
return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF635BFF));
|
||||
return icons[type] ?? const Icon(Icons.description, size: 24, color: Color(0xFF8B9CF7));
|
||||
}
|
||||
|
||||
String _formatDate(DateTime date) {
|
||||
@@ -369,6 +369,35 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
_buildAnalysisSection(analysis),
|
||||
const SizedBox(height: 20),
|
||||
_buildSummarySection(analysis),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('图片加载中...'), duration: Duration(seconds: 2)),
|
||||
);
|
||||
},
|
||||
icon: const Icon(Icons.image),
|
||||
label: const Text('查看原始图片'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: const Color(0xFF8B9CF7),
|
||||
side: const BorderSide(color: Color(0xFF8B9CF7)),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24)),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
onPressed: () => pushRoute(ref, 'aiAnalysis'),
|
||||
style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFF8B9CF7), foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(24))),
|
||||
child: const Text('查看 AI 智能解读'),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 30),
|
||||
]),
|
||||
),
|
||||
@@ -379,7 +408,7 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF5F3FF),
|
||||
color: const Color(0xFFF0F2FF),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
@@ -401,7 +430,7 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE8E6FF), width: 1.5),
|
||||
border: Border.all(color: const Color(0xFFD8DCFD), width: 1.5),
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Padding(
|
||||
@@ -412,6 +441,27 @@ class ReportDetailPage extends ConsumerWidget {
|
||||
const Text('指标分析', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600)),
|
||||
]),
|
||||
),
|
||||
Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 16),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFF3E0),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFFE0B2)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(Icons.info_outline, size: 16, color: Color(0xFFE65100)),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'AI 预解读 · 待医生确认',
|
||||
style: TextStyle(fontSize: 13, color: Color(0xFFE65100), fontWeight: FontWeight.w500),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...analysis.indicators.map((ind) => _buildIndicatorRow(ind)),
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -49,7 +49,7 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
final dndOn = prefs['dndEnabled'] ?? false;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
@@ -107,7 +107,7 @@ class NotificationPrefsPage extends ConsumerWidget {
|
||||
_SwitchTile(
|
||||
icon: Icons.smart_toy_outlined,
|
||||
iconBg: const Color(0xFFF3E5F5),
|
||||
iconColor: const Color(0xFF635BFF),
|
||||
iconColor: const Color(0xFF8B9CF7),
|
||||
title: 'AI 回复通知',
|
||||
subtitle: 'AI 助手回复时发送通知',
|
||||
value: prefs['aiReply'] ?? false,
|
||||
@@ -193,7 +193,7 @@ class _SwitchTile extends StatelessWidget {
|
||||
Text(title, style: const TextStyle(fontSize: 15, color: Color(0xFF1A1A1A), fontWeight: FontWeight.w500)),
|
||||
if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 12, color: Colors.grey[500])),
|
||||
])),
|
||||
Switch(value: value, onChanged: onChanged, activeThumbColor: const Color(0xFF635BFF), activeTrackColor: const Color(0xFFC5BFFF)),
|
||||
Switch(value: value, onChanged: onChanged, activeThumbColor: const Color(0xFF8B9CF7), activeTrackColor: const Color(0xFFC5BFFF)),
|
||||
]),
|
||||
);
|
||||
}
|
||||
@@ -210,7 +210,7 @@ class _TimeButton extends StatelessWidget {
|
||||
return GestureDetector(onTap: onTap, child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE0E0E0)), borderRadius: BorderRadius.circular(10)),
|
||||
child: Column(children: [Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF635BFF)))]),
|
||||
child: Column(children: [Text(label, style: TextStyle(fontSize: 11, color: Colors.grey[500])), Text(time, style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF8B9CF7)))]),
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,17 +8,23 @@ class SettingsPage extends ConsumerWidget {
|
||||
|
||||
@override Widget build(BuildContext context, WidgetRef ref) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF8F7FF),
|
||||
backgroundColor: const Color(0xFFF8F9FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => popRoute(ref)),
|
||||
title: const Text('设置', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(child: SingleChildScrollView(padding: const EdgeInsets.only(bottom: 30), child: Column(children: [
|
||||
Container(width: double.infinity, padding: const EdgeInsets.all(24), decoration: const BoxDecoration(color: Colors.white, borderRadius: BorderRadius.only(bottomLeft: Radius.circular(24), bottomRight: Radius.circular(24))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text('9:41', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Colors.grey[800])), Row(children: [Icon(Icons.wifi, size: 18, color: Colors.grey[700]), const SizedBox(width: 4), Icon(Icons.battery_full, size: 18, color: Colors.grey[700])]),])),
|
||||
const SizedBox(height: 12),
|
||||
_SetItem(icon: Icons.notifications_outlined, title: '消息通知', onTap: () => pushRoute(ref, 'notificationPrefs')),
|
||||
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L'),
|
||||
_SetItem(icon: Icons.data_usage_outlined, title: '数据导出'),
|
||||
_SetItem(icon: Icons.text_fields_outlined, title: '字体大小', trailingText: 'v1.0.0'),
|
||||
_SetItem(icon: Icons.cleaning_services_outlined, title: '清除缓存', subtitle: '73.2 MB'),
|
||||
_SetItem(icon: Icons.info_outline, title: '关于健康管家'),
|
||||
_SetItem(icon: Icons.shield_outlined, title: '隐私协议'),
|
||||
_SetItem(icon: Icons.medication_outlined, title: '用药提醒', subtitle: 'mmHg / mmol/L', onTap: () => pushRoute(ref, 'medications')),
|
||||
_SetItem(icon: Icons.data_usage_outlined, title: '数据导出', onTap: () {}),
|
||||
_SetItem(icon: Icons.text_fields_outlined, title: '字体大小', trailingText: 'v1.0.0', onTap: () {}),
|
||||
_SetItem(icon: Icons.cleaning_services_outlined, title: '清除缓存', subtitle: '73.2 MB', onTap: () {}),
|
||||
_SetItem(icon: Icons.info_outline, title: '关于健康管家', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})),
|
||||
_SetItem(icon: Icons.shield_outlined, title: '隐私协议', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})),
|
||||
const SizedBox(height: 30),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
@@ -66,7 +72,7 @@ class _SetItem extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 14),
|
||||
decoration: BoxDecoration(color: Colors.white),
|
||||
child: Row(children: [
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF635BFF))),
|
||||
Container(width: 36, height: 36, decoration: BoxDecoration(color: const Color(0xFFF0F2FF), borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 18, color: const Color(0xFF8B9CF7))),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [Text(title, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))), if (subtitle != null && subtitle!.isNotEmpty) Text(subtitle!, style: TextStyle(fontSize: 13, color: Colors.grey[500]))])),
|
||||
if (trailingText != null && trailingText!.isNotEmpty) Text(trailingText!, style: TextStyle(fontSize: 14, color: Colors.grey[400])),
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import 'data_providers.dart';
|
||||
import '../utils/sse_handler.dart';
|
||||
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions }
|
||||
enum MessageType { text, dataConfirm, medicationConfirm, dietAnalysis, reportAnalysis, quickOptions, agentWelcome, taskCard }
|
||||
|
||||
class ChatMessage {
|
||||
final String id;
|
||||
@@ -94,7 +95,7 @@ final conversationListProvider = FutureProvider<List<ConversationItem>>((ref) as
|
||||
);
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
return _mockConversations;
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
@@ -110,39 +111,85 @@ ActiveAgent _parseAgent(String? type) {
|
||||
}
|
||||
}
|
||||
|
||||
final _mockConversations = [
|
||||
ConversationItem(
|
||||
id: '1',
|
||||
title: '用药咨询',
|
||||
lastMessage: '阿司匹林应该什么时候吃?',
|
||||
updatedAt: DateTime.now().subtract(const Duration(hours: 2)),
|
||||
agent: ActiveAgent.medication,
|
||||
),
|
||||
ConversationItem(
|
||||
id: '2',
|
||||
title: '血压偏高',
|
||||
lastMessage: '血压145/90,需要注意什么?',
|
||||
updatedAt: DateTime.now().subtract(const Duration(hours: 5)),
|
||||
agent: ActiveAgent.health,
|
||||
),
|
||||
ConversationItem(
|
||||
id: '3',
|
||||
title: '饮食建议',
|
||||
lastMessage: '今天吃了米饭和红烧肉',
|
||||
updatedAt: DateTime.now().subtract(const Duration(days: 1)),
|
||||
agent: ActiveAgent.diet,
|
||||
),
|
||||
];
|
||||
|
||||
class ChatNotifier extends Notifier<ChatState> {
|
||||
StreamSubscription<Map<String, dynamic>>? _subscription;
|
||||
|
||||
@override
|
||||
ChatState build() => const ChatState();
|
||||
ChatState build() {
|
||||
// 首次加载时插入今日任务卡片作为第一条消息
|
||||
Future.microtask(() => insertTaskCard());
|
||||
return const ChatState();
|
||||
}
|
||||
|
||||
void insertTaskCard() {
|
||||
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
|
||||
state = state.copyWith(messages: [ChatMessage(
|
||||
id: 'task_card',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime.now(),
|
||||
type: MessageType.taskCard,
|
||||
), ...state.messages]);
|
||||
}
|
||||
|
||||
void setAgent(ActiveAgent a) {
|
||||
_subscription?.cancel();
|
||||
state = state.activeAgent == a ? const ChatState() : ChatState(activeAgent: a);
|
||||
state = state.copyWith(activeAgent: a);
|
||||
}
|
||||
|
||||
void insertAgentWelcome(ActiveAgent agent) {
|
||||
state = state.copyWith(messages: [...state.messages, ChatMessage(
|
||||
id: 'welcome_${agent.name}_${DateTime.now().millisecondsSinceEpoch}',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: DateTime.now(),
|
||||
type: MessageType.agentWelcome,
|
||||
metadata: {'agent': agent.name},
|
||||
)]);
|
||||
}
|
||||
|
||||
Future<void> sendImage(String imagePath, String text) async {
|
||||
final file = File(imagePath);
|
||||
if (!await file.exists()) return;
|
||||
|
||||
// 先显示用户消息(本地显示图片路径)
|
||||
final userMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}',
|
||||
role: 'user',
|
||||
content: text.isNotEmpty ? text : '[图片]',
|
||||
createdAt: DateTime.now(),
|
||||
metadata: {'localImagePath': imagePath},
|
||||
);
|
||||
state = state.copyWith(messages: [...state.messages, userMsg]);
|
||||
|
||||
// 异步上传图片
|
||||
String? uploadedUrl;
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
uploadedUrl = await api.uploadFile('/api/upload', file);
|
||||
} catch (_) {
|
||||
// 上传失败:保留本地路径,仍然可以本地显示
|
||||
}
|
||||
|
||||
// 更新消息元数据(保留本地路径 + 添加远程URL)
|
||||
final updatedMsgs = state.messages.toList();
|
||||
final idx = updatedMsgs.indexWhere((m) => m.id == userMsg.id);
|
||||
if (idx >= 0) {
|
||||
final meta = <String, dynamic>{'localImagePath': imagePath};
|
||||
if (uploadedUrl != null) meta['imageUrl'] = uploadedUrl;
|
||||
updatedMsgs[idx] = ChatMessage(
|
||||
id: userMsg.id,
|
||||
role: 'user',
|
||||
content: userMsg.content,
|
||||
createdAt: userMsg.createdAt,
|
||||
metadata: meta,
|
||||
);
|
||||
state = state.copyWith(messages: updatedMsgs);
|
||||
}
|
||||
|
||||
// 将图片 URL 作为消息内容发送给 AI
|
||||
final msgWithImage = text.isNotEmpty ? '$text\n[图片已上传]' : '[图片已上传]';
|
||||
await _sendToAI(msgWithImage);
|
||||
}
|
||||
|
||||
Future<void> sendMessage(String text) async {
|
||||
@@ -157,6 +204,10 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
state = state.copyWith(
|
||||
messages: [...state.messages, userMsg], isStreaming: true);
|
||||
|
||||
await _sendToAI(text);
|
||||
}
|
||||
|
||||
Future<void> _sendToAI(String text) async {
|
||||
final aiMsg = ChatMessage(
|
||||
id: '${DateTime.now().millisecondsSinceEpoch}_ai',
|
||||
role: 'assistant',
|
||||
@@ -164,6 +215,8 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
createdAt: DateTime.now(),
|
||||
);
|
||||
|
||||
state = state.copyWith(isStreaming: true);
|
||||
|
||||
try {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) {
|
||||
@@ -236,6 +289,7 @@ class ChatNotifier extends Notifier<ChatState> {
|
||||
case 'diet_analysis': return MessageType.dietAnalysis;
|
||||
case 'report_analysis': return MessageType.reportAnalysis;
|
||||
case 'quick_options': return MessageType.quickOptions;
|
||||
case 'agent_welcome': return MessageType.agentWelcome;
|
||||
default: return MessageType.text;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'auth_provider.dart';
|
||||
import '../services/health_service.dart';
|
||||
|
||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||
return ExerciseService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
/// 健康数据服务
|
||||
final healthServiceProvider = Provider<HealthService>((ref) {
|
||||
return HealthService(ref.watch(apiClientProvider));
|
||||
@@ -23,10 +27,6 @@ final consultationServiceProvider = Provider<ConsultationService>((ref) {
|
||||
return ConsultationService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
final exerciseServiceProvider = Provider<ExerciseService>((ref) {
|
||||
return ExerciseService(ref.watch(apiClientProvider));
|
||||
});
|
||||
|
||||
/// 最新健康数据 Provider
|
||||
final latestHealthProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(healthServiceProvider);
|
||||
@@ -45,12 +45,45 @@ final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((ref)
|
||||
return service.getList();
|
||||
});
|
||||
|
||||
final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(medicationServiceProvider);
|
||||
return service.getReminders();
|
||||
});
|
||||
|
||||
/// 医生列表 Provider
|
||||
final doctorListProvider = FutureProvider<List<Map<String, dynamic>>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
return service.getDoctors();
|
||||
try {
|
||||
return await service.getDoctors().timeout(const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
return _fallbackDoctors;
|
||||
}
|
||||
});
|
||||
|
||||
const _fallbackDoctors = [
|
||||
{
|
||||
'id': 'doc_1',
|
||||
'name': '张医生',
|
||||
'title': '主任医师',
|
||||
'department': '心内科',
|
||||
'introduction': '擅长冠心病、高血压术后管理,20年临床经验',
|
||||
},
|
||||
{
|
||||
'id': 'doc_2',
|
||||
'name': '李医生',
|
||||
'title': '副主任医师',
|
||||
'department': '内分泌科',
|
||||
'introduction': '擅长糖尿病、甲状腺疾病管理,15年临床经验',
|
||||
},
|
||||
{
|
||||
'id': 'doc_3',
|
||||
'name': '王医生',
|
||||
'title': '主治医师',
|
||||
'department': '营养科',
|
||||
'introduction': '擅长术后营养指导、饮食方案制定,10年临床经验',
|
||||
},
|
||||
];
|
||||
|
||||
/// 问诊配额 Provider
|
||||
final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) async {
|
||||
final service = ref.watch(consultationServiceProvider);
|
||||
@@ -60,5 +93,30 @@ final consultationQuotaProvider = FutureProvider<Map<String, dynamic>>((ref) asy
|
||||
/// 当前运动计划 Provider
|
||||
final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
|
||||
final service = ref.watch(exerciseServiceProvider);
|
||||
return service.getCurrentPlan();
|
||||
try {
|
||||
return await service.getCurrentPlan().timeout(const Duration(seconds: 8));
|
||||
} catch (_) {
|
||||
final today = DateTime.now();
|
||||
final monday = today.subtract(Duration(days: today.weekday - 1));
|
||||
return {
|
||||
'weekStartDate': '${monday.year}-${monday.month.toString().padLeft(2, '0')}-${monday.day.toString().padLeft(2, '0')}',
|
||||
'items': List.generate(7, (i) => {
|
||||
'id': 'local_$i',
|
||||
'dayOfWeek': i,
|
||||
'exerciseType': i == 2 || i == 5 ? '休息' : '散步',
|
||||
'durationMinutes': i == 2 || i == 5 ? 0 : 30,
|
||||
'isRestDay': i == 2 || i == 5,
|
||||
'isCompleted': false,
|
||||
}),
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/// 拍照/相册直接触发(无需跳转页面)
|
||||
final cameraActionProvider = NotifierProvider<CameraActionNotifier, String?>(CameraActionNotifier.new);
|
||||
|
||||
class CameraActionNotifier extends Notifier<String?> {
|
||||
@override String? build() => null;
|
||||
void trigger(String action) => state = action;
|
||||
void clear() => state = null;
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ class MedicationService {
|
||||
Future<void> confirm(String id) async {
|
||||
await _api.post('/api/medications/$id/confirm');
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> getReminders() async {
|
||||
final res = await _api.get('/api/medications/reminders');
|
||||
final list = res.data['data'] as List? ?? [];
|
||||
return list.cast<Map<String, dynamic>>();
|
||||
}
|
||||
}
|
||||
|
||||
/// 饮食服务
|
||||
|
||||
@@ -43,16 +43,16 @@ class AgentBar extends ConsumerWidget {
|
||||
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected ? const Color(0xFF635BFF) : Colors.white,
|
||||
border: Border.all(color: const Color(0xFF635BFF)),
|
||||
color: isSelected ? const Color(0xFF8B9CF7) : Colors.white,
|
||||
border: Border.all(color: const Color(0xFF8B9CF7)),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF635BFF)),
|
||||
Icon(icon, size: 16, color: isSelected ? Colors.white : const Color(0xFF8B9CF7)),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF635BFF))),
|
||||
Text(label, style: TextStyle(fontSize: 13, color: isSelected ? Colors.white : const Color(0xFF8B9CF7))),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -5,7 +5,7 @@ import '../providers/auth_provider.dart';
|
||||
import '../providers/data_providers.dart';
|
||||
import '../providers/chat_provider.dart';
|
||||
|
||||
/// 侧滑抽屉——健康概览 + 历史对话 + 菜单
|
||||
/// 侧滑抽屉——彩色分区卡片式设计
|
||||
class HealthDrawer extends ConsumerWidget {
|
||||
const HealthDrawer({super.key});
|
||||
|
||||
@@ -17,208 +17,482 @@ class HealthDrawer extends ConsumerWidget {
|
||||
final conversations = ref.watch(conversationListProvider);
|
||||
|
||||
return Drawer(
|
||||
width: MediaQuery.of(context).size.width * 0.82,
|
||||
backgroundColor: const Color(0xFFFAFBFE),
|
||||
child: SafeArea(
|
||||
child: ListView(
|
||||
padding: EdgeInsets.zero,
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 20),
|
||||
children: [
|
||||
// 用户信息
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
// ════════════ 用户区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFF635BFF),
|
||||
gradientColors: [const Color(0xFF7C74FF), const Color(0xFF5248E8)],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Row(children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: Container(
|
||||
width: 52, height: 52,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(colors: [Colors.white.withAlpha(40), Colors.white.withAlpha(15)]),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white30, width: 1.5),
|
||||
),
|
||||
child: user?.avatarUrl != null
|
||||
? ClipOval(child: Image.network(user!.avatarUrl!, fit: BoxFit.cover, errorBuilder: (_, e, s) => _defaultAvatar()))
|
||||
: _defaultAvatar(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(user?.name ?? '未设置昵称', style: const TextStyle(fontSize: 17, fontWeight: FontWeight.w700, color: Colors.white)),
|
||||
const SizedBox(height: 2),
|
||||
Text(user?.phone ?? '未登录', style: TextStyle(fontSize: 12, color: Colors.white70)),
|
||||
],
|
||||
)),
|
||||
Icon(Icons.chevron_right, size: 18, color: Colors.white54),
|
||||
]),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ════════════ 健康概览区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFE8F0FE),
|
||||
gradientColors: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'profile'),
|
||||
child: CircleAvatar(
|
||||
radius: 28,
|
||||
backgroundColor: const Color(0xFFEDEBFF),
|
||||
child: Icon(Icons.person, size: 32, color: Theme.of(context).colorScheme.primary),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF635BFF).withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.monitor_heart_rounded, size: 13, color: const Color(0xFF635BFF)),
|
||||
SizedBox(width: 4),
|
||||
Text('健康概览', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF635BFF))),
|
||||
]),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => pushRoute(ref, 'trend'),
|
||||
child: const Padding(padding: EdgeInsets.all(4), child: Text('详情', style: TextStyle(fontSize: 11, color: Color(0xFF888888)))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
latestHealth.when(
|
||||
data: (data) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: _bpText(data['BloodPressure']), accentColor: const Color(0xFFFF6B6B), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: _metricVal(data['HeartRate']), unit: '', accentColor: const Color(0xFFFF9F43), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: _metricVal(data['Glucose']), unit: '', accentColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: _metricVal(data['SpO2']), unit: '%', accentColor: const Color(0xFF4D96FF), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: _metricVal(data['Weight']), unit: 'kg', accentColor: const Color(0xFFA55EEA), onTap: () => pushRoute(ref, 'trend', params: {'type': 'weight'})),
|
||||
],
|
||||
),
|
||||
),
|
||||
loading: () => const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: SizedBox(width: 22, height: 22, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF635BFF))))),
|
||||
error: (Object err, StackTrace st) => Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 14),
|
||||
child: Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_MetricTile(icon: Icons.favorite_rounded, label: '血压', value: '--', accentColor: const Color(0xFFFF6B6B)),
|
||||
_MetricTile(icon: Icons.monitor_heart_outlined, label: '心率', value: '--', accentColor: const Color(0xFFFF9F43)),
|
||||
_MetricTile(icon: Icons.bloodtype_outlined, label: '血糖', value: '--', accentColor: const Color(0xFF26C281)),
|
||||
_MetricTile(icon: Icons.air_outlined, label: '血氧', value: '--', accentColor: const Color(0xFF4D96FF)),
|
||||
_MetricTile(icon: Icons.monitor_weight_outlined, label: '体重', value: '--', accentColor: const Color(0xFFA55EEA)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(user?.name ?? '未设置昵称', style: Theme.of(context).textTheme.titleMedium),
|
||||
if (user != null) const SizedBox(height: 4),
|
||||
Text(user?.phone ?? '', style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
),
|
||||
_DrawerItem(icon: Icons.settings, label: '设置', onTap: () => pushRoute(ref, 'settings')),
|
||||
const Divider(),
|
||||
|
||||
// 健康概览——接真实数据
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Text('健康概览', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
||||
),
|
||||
latestHealth.when(
|
||||
data: (data) => Column(children: [
|
||||
_HealthMetric(icon: Icons.favorite, label: '血压', value: _bpText(data['BloodPressure']), onTap: () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'})),
|
||||
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: _metricText(data['HeartRate'], '次/分'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'})),
|
||||
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: _metricText(data['Glucose'], 'mmol/L'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'glucose'})),
|
||||
_HealthMetric(icon: Icons.air, label: '血氧', value: _metricText(data['SpO2'], '%'), onTap: () => pushRoute(ref, 'trend', params: {'type': 'spo2'})),
|
||||
]),
|
||||
loading: () => const Padding(padding: EdgeInsets.all(16), child: Center(child: SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)))),
|
||||
error: (_, _) => Column(children: [
|
||||
_HealthMetric(icon: Icons.favorite, label: '血压', value: '--'),
|
||||
_HealthMetric(icon: Icons.monitor_heart, label: '心率', value: '--'),
|
||||
_HealthMetric(icon: Icons.bloodtype, label: '血糖', value: '--'),
|
||||
_HealthMetric(icon: Icons.air, label: '血氧', value: '--'),
|
||||
]),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
const Divider(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
|
||||
child: Row(children: [
|
||||
Text('历史对话', style: Theme.of(context).textTheme.labelMedium!.copyWith(fontWeight: FontWeight.w600)),
|
||||
const Spacer(),
|
||||
TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))),
|
||||
]),
|
||||
),
|
||||
conversations.when(
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Padding(padding: EdgeInsets.symmetric(vertical: 20), child: Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 13))));
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: items.map((item) => _ConversationItem(item: item, ref: ref)).toList(),
|
||||
// ════════════ 功能区(横向排布)════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFFDF6EC),
|
||||
gradientColors: null,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF0A060).withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.apps_rounded, size: 13, color: Color(0xFFF0A060)),
|
||||
SizedBox(width: 4),
|
||||
Text('功能', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFFF0A060))),
|
||||
]),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator(strokeWidth: 2)),
|
||||
error: (_, __) => const Center(child: Text('加载失败', style: TextStyle(color: Color(0xFF999999), fontSize: 14))),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_FeatureChip(icon: Icons.description_outlined, label: '报告管理', bgColor: const Color(0xFFFFEDE0), iconColor: const Color(0xFFF0A060), onTap: () => pushRoute(ref, 'reports')),
|
||||
_FeatureChip(icon: Icons.calendar_today_outlined, label: '健康日历', bgColor: const Color(0xFFE0F0E0), iconColor: const Color(0xFF26C281), onTap: () => pushRoute(ref, 'calendar')),
|
||||
_FeatureChip(icon: Icons.restaurant_outlined, label: '饮食记录', bgColor: const Color(0xFFFFE8E0), iconColor: const Color(0xFFFF8C42), onTap: () => pushRoute(ref, 'dietRecords')),
|
||||
_FeatureChip(icon: Icons.event_note_outlined, label: '复查随访', bgColor: const Color(0xFFE8E0FF), iconColor: const Color(0xFF8B6CF7), onTap: () => pushRoute(ref, 'followups')),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(),
|
||||
_DrawerItem(icon: Icons.logout, label: '退出登录', onTap: () async {
|
||||
final ok = await showDialog<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text('退出登录'), content: const Text('确定退出?'),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
|
||||
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
||||
}),
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ════════════ 历史对话区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFF0F4FF),
|
||||
gradientColors: null,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 4),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF4D96FF).withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: const Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.history_rounded, size: 13, color: Color(0xFF4D96FF)),
|
||||
SizedBox(width: 4),
|
||||
Text('历史对话', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w600, color: Color(0xFF4D96FF))),
|
||||
]),
|
||||
),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => ref.invalidate(conversationListProvider),
|
||||
child: const Padding(padding: EdgeInsets.all(4), child: Icon(Icons.refresh, size: 15, color: Color(0xFFAAAAAA))),
|
||||
),
|
||||
]),
|
||||
),
|
||||
_buildConversationList(ref, conversations),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 10),
|
||||
|
||||
// ════════════ 设置区 ════════════
|
||||
_SectionCard(
|
||||
color: const Color(0xFFF5F5F7),
|
||||
gradientColors: null,
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: () => pushRoute(ref, 'settings'),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
child: Row(children: [
|
||||
Container(
|
||||
width: 34, height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEEEEEE),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.settings_outlined, size: 18, color: Color(0xFF666666)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(child: Text('设置', style: TextStyle(fontSize: 15, fontWeight: FontWeight.w500, color: Color(0xFF333333)))),
|
||||
const Icon(Icons.chevron_right, size: 16, color: Color(0xFFCCCCCC)),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _defaultAvatar() => const Icon(Icons.person, size: 26, color: Colors.white70);
|
||||
|
||||
String _bpText(dynamic bp) {
|
||||
if (bp == null) return '--';
|
||||
if (bp is Map) return '${bp['systolic'] ?? '--'}/${bp['diastolic'] ?? '--'}';
|
||||
return '--';
|
||||
}
|
||||
|
||||
String _metricText(dynamic metric, String unit) {
|
||||
String _metricVal(dynamic metric) {
|
||||
if (metric == null) return '--';
|
||||
if (metric is Map) {
|
||||
final v = metric['value'];
|
||||
return v != null ? '$v $unit' : '--';
|
||||
}
|
||||
return '--';
|
||||
if (metric is Map) { final v = metric['value']; return v?.toString() ?? '--'; }
|
||||
return metric.toString();
|
||||
}
|
||||
|
||||
Widget _buildConversationList(WidgetRef ref, AsyncValue<List<ConversationItem>> conversations) {
|
||||
return conversations.when(
|
||||
data: (items) {
|
||||
if (items.isEmpty) {
|
||||
return const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text('暂无历史对话', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
|
||||
),
|
||||
);
|
||||
}
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 14),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: items.map((item) => _ConversationItem(item: item)).toList(),
|
||||
),
|
||||
);
|
||||
},
|
||||
loading: () => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF4D96FF)),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (Object err, StackTrace st) => const Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 16),
|
||||
child: Center(
|
||||
child: Text('加载失败', style: TextStyle(color: Color(0xFFBBBBBB), fontSize: 13)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DrawerItem extends StatelessWidget {
|
||||
final IconData icon; final String label; final VoidCallback onTap;
|
||||
const _DrawerItem({required this.icon, required this.label, required this.onTap});
|
||||
@override Widget build(BuildContext context) => ListTile(leading: Icon(icon, size: 20, color: const Color(0xFF666666)), title: Text(label, style: const TextStyle(fontSize: 16)), onTap: onTap, dense: true);
|
||||
}
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 分区卡片容器 —— 带圆角、阴影和微动效
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class _HealthMetric extends StatelessWidget {
|
||||
final IconData icon; final String label; final String value; final VoidCallback? onTap;
|
||||
const _HealthMetric({required this.icon, required this.label, required this.value, this.onTap});
|
||||
@override Widget build(BuildContext context) => ListTile(
|
||||
leading: Icon(icon, size: 18, color: const Color(0xFF635BFF)),
|
||||
title: Text(label, style: const TextStyle(fontSize: 16, color: Color(0xFF1A1A1A))),
|
||||
trailing: Text(value, style: TextStyle(fontSize: 16, color: value == '--' ? const Color(0xFF999999) : const Color(0xFF1A1A1A))),
|
||||
dense: true,
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
class _SectionCard extends StatelessWidget {
|
||||
final Widget child;
|
||||
final Color color;
|
||||
final List<Color>? gradientColors;
|
||||
|
||||
class _ConversationItem extends ConsumerWidget {
|
||||
final ConversationItem item;
|
||||
final WidgetRef ref;
|
||||
const _ConversationItem({required this.item, required this.ref});
|
||||
const _SectionCard({required this.child, required this.color, this.gradientColors});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
Widget build(BuildContext context) {
|
||||
return TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 0.0, end: 1.0),
|
||||
duration: const Duration(milliseconds: 500),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (context, value, child) => Transform.translate(
|
||||
offset: Offset(0, 8 * (1 - value)),
|
||||
child: Opacity(opacity: value, child: child),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: gradientColors == null ? color : null,
|
||||
gradient: gradientColors != null ? LinearGradient(
|
||||
colors: gradientColors!,
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
) : null,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: (gradientColors?.first ?? color).withAlpha(25),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 健康指标小方块
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class _MetricTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final String? unit;
|
||||
final Color accentColor;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
const _MetricTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
this.unit,
|
||||
required this.accentColor,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
width: ((MediaQuery.of(context).size.width * 0.82 - 48) / 3).floorToDouble(),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: accentColor.withAlpha(30)),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 28, height: 28,
|
||||
decoration: BoxDecoration(
|
||||
color: accentColor.withAlpha(15),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(icon, size: 15, color: accentColor),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(value, style: TextStyle(fontSize: 13, fontWeight: FontWeight.w700, color: const Color(0xFF1A1A1A))),
|
||||
Text(label, style: TextStyle(fontSize: 10, color: Colors.grey[500])),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 功能按钮(横向)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class _FeatureChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Color bgColor;
|
||||
final Color iconColor;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _FeatureChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.bgColor,
|
||||
required this.iconColor,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: Colors.transparent,
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: bgColor,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(icon, size: 17, color: iconColor),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500, color: iconColor.withAlpha(220))),
|
||||
]),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 历史对话项
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
class _ConversationItem extends StatelessWidget {
|
||||
final ConversationItem item;
|
||||
|
||||
const _ConversationItem({required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colors = _conversationColors(item.agent);
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
margin: const EdgeInsets.symmetric(vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8F7FF),
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: colors.$1.withAlpha(80)),
|
||||
),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 10, vertical: 2),
|
||||
leading: Container(
|
||||
width: 36,
|
||||
height: 36,
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEDEBFF),
|
||||
gradient: LinearGradient(colors: [colors.$2.withAlpha(30), colors.$2.withAlpha(15)]),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Icon(_getAgentIcon(item.agent), size: 16, color: const Color(0xFF635BFF)),
|
||||
child: Icon(_getAgentIcon(item.agent), size: 15, color: colors.$2),
|
||||
),
|
||||
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500)),
|
||||
title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 13, fontWeight: FontWeight.w600, color: Color(0xFF333333))),
|
||||
subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: Colors.grey[500])),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(_formatTime(item.updatedAt), style: const TextStyle(fontSize: 9, color: Color(0xFFCCCCCC))),
|
||||
Text(_formatTime(item.updatedAt), style: TextStyle(fontSize: 9, color: Colors.grey[400])),
|
||||
const SizedBox(height: 2),
|
||||
PopupMenuButton<int>(
|
||||
icon: const Icon(Icons.more_vert, size: 14, color: Color(0xFFCCCCCC)),
|
||||
itemBuilder: (_) => [
|
||||
const PopupMenuItem(value: 1, child: Text('继续聊')),
|
||||
const PopupMenuItem(value: 2, child: Text('删除')),
|
||||
],
|
||||
onSelected: (v) async {
|
||||
if (v == 1) {
|
||||
ref.read(chatProvider.notifier).setAgent(item.agent);
|
||||
Navigator.pop(context);
|
||||
} else if (v == 2) {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('删除对话'),
|
||||
content: const Text('确定删除该对话?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
ref.invalidate(conversationListProvider);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
Icon(Icons.chevron_right, size: 12, color: Colors.grey[300]),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
ref.read(chatProvider.notifier).setAgent(item.agent);
|
||||
Navigator.pop(context);
|
||||
},
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _getAgentIcon(ActiveAgent agent) {
|
||||
switch (agent) {
|
||||
case ActiveAgent.health: return Icons.health_and_safety;
|
||||
case ActiveAgent.diet: return Icons.restaurant;
|
||||
case ActiveAgent.medication: return Icons.medication;
|
||||
case ActiveAgent.report: return Icons.file_open;
|
||||
case ActiveAgent.exercise: return Icons.directions_run;
|
||||
case ActiveAgent.consultation: return Icons.chat;
|
||||
default: return Icons.chat_bubble_outline;
|
||||
}
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => Icons.health_and_safety_outlined,
|
||||
ActiveAgent.diet => Icons.restaurant_outlined,
|
||||
ActiveAgent.medication => Icons.medication_outlined,
|
||||
ActiveAgent.report => Icons.description_outlined,
|
||||
ActiveAgent.exercise => Icons.directions_run_outlined,
|
||||
ActiveAgent.consultation => Icons.chat_bubble_outline,
|
||||
_ => Icons.chat_bubble_outline,
|
||||
};
|
||||
}
|
||||
|
||||
String _formatTime(DateTime time) {
|
||||
@@ -230,3 +504,19 @@ class _ConversationItem extends ConsumerWidget {
|
||||
return '${time.month}/${time.day}';
|
||||
}
|
||||
}
|
||||
|
||||
(_ColorSet bg, _ColorSet accent) _conversationColors(ActiveAgent agent) {
|
||||
return switch (agent) {
|
||||
ActiveAgent.health => (const _ColorSet(0xFFE8F5E9), const _ColorSet(0xFF26C281)),
|
||||
ActiveAgent.diet => (const _ColorSet(0xFFFFF3E0), const _ColorSet(0xFFFF8C42)),
|
||||
ActiveAgent.medication => (const _ColorSet(0xFFFFEBEE), const _ColorSet(0xFFE898A8)),
|
||||
ActiveAgent.report => (const _ColorSet(0xFFEDE7F6), const _ColorSet(0xFF8B6CF7)),
|
||||
ActiveAgent.exercise => (const _ColorSet(0xFFE0F7FA), const _ColorSet(0xFF00BCD4)),
|
||||
ActiveAgent.consultation => (const _ColorSet(0xFFE3F2FD), const _ColorSet(0xFF4D96FF)),
|
||||
_ => (const _ColorSet(0xFFF5F5F5), const _ColorSet(0xFF999999)),
|
||||
};
|
||||
}
|
||||
|
||||
class _ColorSet extends Color {
|
||||
const _ColorSet(int super.value);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user