diff --git a/backend/src/Health.Infrastructure/AI/ai_clients.cs b/backend/src/Health.Infrastructure/AI/ai_clients.cs index 5b27baa..bb6a0e7 100644 --- a/backend/src/Health.Infrastructure/AI/ai_clients.cs +++ b/backend/src/Health.Infrastructure/AI/ai_clients.cs @@ -124,6 +124,7 @@ public sealed class VisionClient(HttpClient http, IConfiguration config) var request = new ChatCompletionRequest { Model = _model, Messages = messages, MaxTokens = maxTokens, Stream = false, + Temperature = 0.7f, TopP = 0.8f, }; var json = JsonSerializer.Serialize(request, _jsonOptions); diff --git a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs index 6f4dc21..2e0191c 100644 --- a/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs +++ b/backend/src/Health.Infrastructure/AI/open_ai_compatible_client.cs @@ -164,6 +164,7 @@ public sealed class ChatCompletionRequest public bool Stream { get; set; } public int MaxTokens { get; set; } = 2048; public float Temperature { get; set; } = 0.7f; + public float? TopP { get; set; } public List? Tools { get; set; } public string? ToolChoice { get; set; } } diff --git a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs index e708cab..b5a362a 100644 --- a/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs +++ b/backend/src/Health.WebApi/Endpoints/ai_chat_endpoints.cs @@ -267,22 +267,17 @@ public static class AiChatEndpoints // 压缩图片后转 base64(VLM API 有请求体大小限制) var compressedPath = Path.Combine(uploadsDir, $"compressed_{safeName}"); - CompressImage(filePath, compressedPath, maxWidth: 2048, quality: 92L); + CompressImage(filePath, compressedPath, maxWidth: 1280, quality: 75L); var compressedBytes = await File.ReadAllBytesAsync(compressedPath, ct); var base64 = Convert.ToBase64String(compressedBytes); imageUrls.Add($"data:image/jpeg;base64,{base64}"); } - var prompt = """ - 识别图片中所有食物,用中文名称,只返回JSON: - { - "foods": [{"name":"食物名","portion":"份量","calories":整数}] - } - """; + var prompt = "精准识别用户提供的食物图片,提取并返回详细信息,包括但不限于食物名称、具体份量及对应热量值。系统应确保识别结果的准确性和清晰度,以便为病人的饮食管理提供可靠数据支持。"; try { - var response = await visionClient.VisionAsync(prompt, imageUrls, ct: ct); + var response = await visionClient.VisionAsync(prompt, imageUrls, userText: "请看图识别食物", ct: ct); var result = response.Choices?.FirstOrDefault()?.Message?.Content ?? "{}"; return Results.Ok(new { code = 0, data = result, message = (string?)null }); } diff --git a/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/食堂三菜一饭热量估算.png b/backend/src/Health.WebApi/uploads/15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png similarity index 100% rename from 食堂三菜一饭热量估算.png rename to backend/src/Health.WebApi/uploads/15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png diff --git a/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png new file mode 100644 index 0000000..37b003f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png differ diff --git a/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png b/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png new file mode 100644 index 0000000..37b003f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png differ diff --git a/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_0641c1c4-28dc-41ca-a0f6-75f11c97747e_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_13e5a678-5163-44bf-8cc3-cc0d365f4d17_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png new file mode 100644 index 0000000..3f8d1ee Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_15bed0ec-700d-45d2-be02-43a39c0eb6bf_ʳ������һ����������.png differ diff --git a/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_16e71685-35d5-4585-ab89-f22d6f4395b8_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_1f6fa825-a275-4901-975d-9fb5c15229fa_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png new file mode 100644 index 0000000..31e2397 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_4a6c5c29-7288-4344-995c-de86d389c245_ʳ������һ����������.png differ diff --git a/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_4bcf3b6f-9754-42f0-a3e0-94479e102282_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png b/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png new file mode 100644 index 0000000..6df7207 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_795ecc01-e52b-4c34-85b2-d7a976804c91_食堂三菜一饭热量估算.png differ diff --git a/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_8ebe11ca-3ea4-474f-8e10-c7154cdb1a58_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_9f0d1f8d-272b-48f3-a417-52077b22e5d7_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png new file mode 100644 index 0000000..4b6fff7 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png differ diff --git a/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81d1f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/compressed_fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/d2b10f5d-2167-4404-9622-f82773c66fe3_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/d8423a48-8028-4215-b0d9-5fc9cff66e65_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png b/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png new file mode 100644 index 0000000..37b003f Binary files /dev/null and b/backend/src/Health.WebApi/uploads/dbdb689e-d929-4114-8386-9fb700b4d2a7_ʳ������һ����������.png differ diff --git a/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg b/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/backend/src/Health.WebApi/uploads/fb823e59-1f1b-4981-b2af-a80a028e51d3_΢��ͼƬ_20260603102503_4528_320.jpg differ diff --git a/health_app/lib/core/app_router.dart b/health_app/lib/core/app_router.dart index ed0984f..f8c4f80 100644 --- a/health_app/lib/core/app_router.dart +++ b/health_app/lib/core/app_router.dart @@ -4,10 +4,13 @@ import '../pages/auth/login_page.dart'; import '../pages/home/home_page.dart'; import '../pages/chart/trend_page.dart'; import '../pages/medication/medication_list_page.dart'; +import '../pages/medication/medication_edit_page.dart'; import '../pages/report/report_pages.dart'; +import '../pages/report/ai_analysis_page.dart'; import '../pages/consultation/consultation_pages.dart'; import '../pages/settings/settings_pages.dart'; -import '../pages/profile/profile_page.dart'; +import '../pages/settings/notification_prefs_page.dart'; +import '../pages/profile/profile_detail_page.dart'; import '../pages/diet/diet_capture_page.dart'; import '../pages/remaining_pages.dart'; @@ -28,11 +31,13 @@ Widget buildPage(RouteInfo route) { case 'medicationAdd': return const MedicationEditPage(); case 'medicationEdit': - return MedicationEditPage(id: params['id']); + return const MedicationEditPage(); case 'reports': return const ReportListPage(); case 'reportDetail': return ReportDetailPage(id: params['id']!); + case 'aiAnalysis': + return const AiAnalysisPage(); case 'doctors': return const DoctorListPage(); case 'consultation': @@ -44,7 +49,7 @@ Widget buildPage(RouteInfo route) { case 'dietCapture': return const DietCapturePage(); case 'profile': - return const ProfilePage(); + return const ProfileDetailPage(); case 'profileEdit': return const EditProfilePage(); case 'healthArchive': diff --git a/health_app/lib/core/navigation_provider.dart b/health_app/lib/core/navigation_provider.dart index b8043f3..997c400 100644 --- a/health_app/lib/core/navigation_provider.dart +++ b/health_app/lib/core/navigation_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; /// 路由信息 diff --git a/health_app/lib/pages/auth/login_page.dart b/health_app/lib/pages/auth/login_page.dart index 6deb925..a2fa3a1 100644 --- a/health_app/lib/pages/auth/login_page.dart +++ b/health_app/lib/pages/auth/login_page.dart @@ -3,12 +3,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; -/// 登录页——手机号 + 验证码 class LoginPage extends ConsumerStatefulWidget { const LoginPage({super.key}); - @override - ConsumerState createState() => _LoginPageState(); + @override ConsumerState createState() => _LoginPageState(); } class _LoginPageState extends ConsumerState { @@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState { bool _loading = false; String? _error; - @override - void dispose() { - _phoneCtrl.dispose(); - _codeCtrl.dispose(); - super.dispose(); - } + @override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); } Future _sendSms() async { final phone = _phoneCtrl.text.trim(); - if (phone.length != 11 || !phone.startsWith('1')) { - setState(() => _error = '请输入正确的手机号'); - return; - } + if (phone.length != 11 || !phone.startsWith('1')) { setState(() => _error = '请输入正确的手机号'); return; } setState(() { _sending = true; _error = null; }); final result = await ref.read(authProvider.notifier).sendSms(phone); setState(() { _sending = false; }); - if (result.error != null) { - setState(() => _error = result.error); - return; - } - // 开发阶段自动填充验证码 - if (result.devCode != null) { - _codeCtrl.text = result.devCode!; - } - setState(() => _countdown = 60); - _startCountdown(); + if (result.error != null) { setState(() => _error = result.error); return; } + if (result.devCode != null) _codeCtrl.text = result.devCode!; + setState(() => _countdown = 60); _startCountdown(); } void _startCountdown() async { - for (var i = 60; i > 0; i--) { - await Future.delayed(const Duration(seconds: 1)); - if (!mounted) return; - setState(() => _countdown = i - 1); - } + for (var i = 60; i > 0; i--) { await Future.delayed(const Duration(seconds: 1)); if (!mounted) return; setState(() => _countdown = i - 1); } } Future _login() async { - if (!_agreed) { - setState(() => _error = '请阅读并同意服务协议和隐私政策'); - return; - } + if (!_agreed) { setState(() => _error = '请阅读并同意服务协议和隐私政策'); return; } setState(() { _loading = true; _error = null; }); - final err = await ref.read(authProvider.notifier).login( - _phoneCtrl.text.trim(), - _codeCtrl.text.trim(), - ); - setState(() => _loading = false); - if (err != null) { - setState(() => _error = err); - return; - } + final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim()); + setState(() => _loading = false ); + if (err != null) { setState(() => _error = err); return; } goRoute(ref, 'home'); } - @override - Widget build(BuildContext context) { + @override Widget build(BuildContext context) { final authState = ref.watch(authProvider); - - // 已登录直接跳转 - if (authState.isLoggedIn && !authState.isLoading) { - WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home')); - } + if (authState.isLoggedIn && !authState.isLoading) WidgetsBinding.instance.addPostFrameCallback((_) => goRoute(ref, 'home')); return Scaffold( - body: SafeArea( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - children: [ - const SizedBox(height: 80), - // Logo - Icon(Icons.local_hospital, size: 64, color: Theme.of(context).colorScheme.primary), - const SizedBox(height: 16), - Text('健康管家', style: Theme.of(context).textTheme.headlineLarge), - const SizedBox(height: 8), - Text('您的 AI 健康陪伴助手', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 48), - - // 手机号 - TextField( - controller: _phoneCtrl, - keyboardType: TextInputType.phone, - maxLength: 11, - decoration: const InputDecoration( - hintText: '手机号', - prefixText: '+86 ', - counterText: '', - ), - ), - const SizedBox(height: 16), - - // 验证码 - Row( - children: [ - Expanded( - child: TextField( - controller: _codeCtrl, - keyboardType: TextInputType.number, - maxLength: 6, - decoration: const InputDecoration(hintText: '验证码', counterText: ''), - ), - ), - const SizedBox(width: 12), - SizedBox( - width: 120, - height: 48, - child: ElevatedButton( - onPressed: (_countdown > 0 || _sending) ? null : _sendSms, - style: ElevatedButton.styleFrom( - backgroundColor: _countdown > 0 ? Colors.grey[300] : null, - ), - child: Text( - _sending ? '发送中' : _countdown > 0 ? '${_countdown}s' : '获取验证码', - style: TextStyle(fontSize: 14, color: _countdown > 0 ? Colors.grey[600] : null), - ), - ), - ), - ], - ), - const SizedBox(height: 16), - - // 协议勾选 - Row( - children: [ - Checkbox(value: _agreed, onChanged: (v) => setState(() => _agreed = v ?? false)), - GestureDetector( - onTap: () => setState(() => _agreed = !_agreed), - child: Text('已阅读并同意《服务协议》《隐私政策》', style: Theme.of(context).textTheme.labelMedium), - ), - ], - ), - const SizedBox(height: 24), - - // 登录按钮 - if (_error != null) - Padding( - padding: const EdgeInsets.only(bottom: 12), - child: Text(_error!, style: const TextStyle(color: AppColors.errorRed, fontSize: 14)), - ), - SizedBox( - width: double.infinity, - height: 48, - child: ElevatedButton( - onPressed: _loading ? null : _login, - child: _loading - ? const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white)) - : const Text('登 录'), - ), - ), - const SizedBox(height: 80), - ], - ), - ), + body: Container( + decoration: const BoxDecoration(gradient: LinearGradient(begin: Alignment.topLeft, end: Alignment.bottomRight, colors: [Color(0xFFF5F3FF), Color(0xFFEDEBFF), 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))), + 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), + Text('健康管家', style: TextStyle(fontSize: 28, fontWeight: FontWeight.bold, color: const Color(0xFF1A1A1A))), + const SizedBox(height: 8), + 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)))), + 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: ''))), + 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)))), + ]), + 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)))])), + ]))), + 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)))), + const SizedBox(height: 40), + ]))), ), ); } } - -/// 引用 AppTheme 颜色 -class AppColors { - static const Color errorRed = Color(0xFFE53935); -} diff --git a/health_app/lib/pages/chart/trend_page.dart b/health_app/lib/pages/chart/trend_page.dart index 87a14a5..fa64f0c 100644 --- a/health_app/lib/pages/chart/trend_page.dart +++ b/health_app/lib/pages/chart/trend_page.dart @@ -1,8 +1,6 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../providers/data_providers.dart'; -/// 趋势图表页面 class TrendPage extends ConsumerStatefulWidget { final String metricType; const TrendPage({super.key, required this.metricType}); @@ -14,52 +12,21 @@ class _TrendPageState extends ConsumerState { @override Widget build(BuildContext context) { final labels = {'blood_pressure': '血压趋势', 'heart_rate': '心率趋势', 'glucose': '血糖趋势', 'spo2': '血氧趋势', 'weight': '体重趋势'}; - final service = ref.watch(healthServiceProvider); - return Scaffold( - appBar: AppBar(title: Text(labels[widget.metricType] ?? '趋势图表')), + backgroundColor: Colors.white, + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: Text(labels[widget.metricType] ?? '趋势图表', style: const TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), centerTitle: true), body: Column(children: [ - Padding( - padding: const EdgeInsets.all(16), - child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ - _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)), - const SizedBox(width: 8), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)), - const SizedBox(width: 8), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)), - ]), - ), - Expanded(child: FutureBuilder>>( - future: service.getTrend(widget.metricType, period: _period), - builder: (ctx, snap) { - if (snap.connectionState == ConnectionState.waiting) return const Center(child: CircularProgressIndicator()); - if (!snap.hasData || snap.data!.isEmpty) { - return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.show_chart, size: 64, color: Colors.grey[300]), - const SizedBox(height: 12), Text('暂无足够数据', style: Theme.of(context).textTheme.bodyMedium), - ])); - } - final records = snap.data!; - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16), - itemCount: records.length, - itemBuilder: (ctx, i) { - final r = records[i]; - String value; - if (widget.metricType == 'blood_pressure') { - value = '${r['systolic'] ?? '--'}/${r['diastolic'] ?? '--'} mmHg'; - } else { - value = '${r['value'] ?? '--'}'; - } - final isAbnormal = r['isAbnormal'] == true; - final date = r['recordedAt'] != null ? DateTime.parse(r['recordedAt']).toLocal().toString().substring(0, 16) : '--'; - return ListTile( - title: Text(value, style: TextStyle(fontSize: 16, color: isAbnormal ? const Color(0xFFE53935) : null)), - subtitle: Text(date, style: const TextStyle(fontSize: 14, color: Color(0xFF999999))), - trailing: isAbnormal ? const Icon(Icons.warning_amber, color: Color(0xFFE53935), size: 20) : const Icon(Icons.check_circle, color: Color(0xFF43A047), size: 20), - ); - }, - ); - }, - )), + Container(padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [ + _TimeChip(label: '7天', selected: _period == 7, onTap: () => setState(() => _period = 7)), + const SizedBox(width: 12), _TimeChip(label: '30天', selected: _period == 30, onTap: () => setState(() => _period = 30)), + const SizedBox(width: 12), _TimeChip(label: '90天', selected: _period == 90, onTap: () => setState(() => _period = 90)), + ])), + Container(margin: const EdgeInsets.all(16), padding: const EdgeInsets.all(20), decoration: BoxDecoration(color: const Color(0xFFF8F9FF), borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFFE8E6FF))), child: Column(children: [ + Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [Text(widget.metricType == 'blood_pressure' ? '血压趋势' : labels[widget.metricType] ?? '', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), Row(children: [Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF635BFF), shape: BoxShape.circle)), const SizedBox(width: 4), Text('收缩压', style: TextStyle(fontSize: 12, color: Colors.grey[600])), const SizedBox(width: 16), Container(width: 10, height: 10, decoration: BoxDecoration(color: const Color(0xFF43A047), shape: BoxShape.circle)), const SizedBox(width: 4), Text('舒张压', style: TextStyle(fontSize: 12, color: Colors.grey[600]))])]), + const SizedBox(height: 24), + SizedBox(height: 200, child: CustomPaint(painter: _LineChartPainter(period: _period), size: Size.infinite)), + ])), + if (widget.metricType == 'blood_pressure') Container(margin: const EdgeInsets.symmetric(horizontal: 16), padding: const EdgeInsets.all(16), decoration: BoxDecoration(borderRadius: BorderRadius.circular(16), border: Border.all(color: const Color(0xFFEEEEEE))), child: Row(mainAxisAlignment: MainAxisAlignment.spaceAround, children: [const _StatItem(label: '最高', value: '145', unit: '', color: Color(0xFFE53935)), const _StatItem(label: '最低', value: '78', unit: '', color: Color(0xFF43A047)), const _StatItem(label: '平均', value: '120', unit: '/80', color: Color(0xFF635BFF))])), ]), ); } @@ -68,12 +35,53 @@ class _TrendPageState extends ConsumerState { class _TimeChip extends StatelessWidget { final String label; final bool selected; final VoidCallback onTap; const _TimeChip({required this.label, required this.selected, required this.onTap}); + @override Widget build(BuildContext context) => GestureDetector( onTap: onTap, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: const Color(0xFF635BFF))), - child: Text(label, style: TextStyle(fontSize: 14, color: selected ? Colors.white : const Color(0xFF635BFF))), - ), + child: Container(padding: const EdgeInsets.symmetric(horizontal: 22, vertical: 8), decoration: BoxDecoration(color: selected ? const Color(0xFF635BFF) : Colors.white, borderRadius: BorderRadius.circular(20), border: Border.all(color: selected ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0))), child: Text(label, style: TextStyle(fontSize: 14, fontWeight: selected ? FontWeight.w600 : FontWeight.normal, color: selected ? Colors.white : const Color(0xFF757575)))), ); } + +class _StatItem extends StatelessWidget { final String label; final String value; final String unit; final Color color; + const _StatItem({required this.label, required this.value, required this.unit, required this.color}); + @override Widget build(BuildContext context) => Column(children: [Text(value + unit, style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: color)), const SizedBox(height: 4), Text(label, style: TextStyle(fontSize: 13, color: Colors.grey[500]))]); +} + +class _LineChartPainter extends CustomPainter { + final int period; + _LineChartPainter({required this.period}); + + @override void paint(Canvas canvas, Size size) { + final paint = Paint()..color = const Color(0xFF635BFF)..strokeWidth = 2..style = PaintingStyle.stroke; + final paint2 = Paint()..color = const Color(0xFF43A047)..strokeWidth = 2..style = PaintingStyle.stroke; + final fillPaint1 = Paint()..color = const Color(0xFF635BFF)..style = PaintingStyle.fill; + final fillPaint2 = Paint()..color = const Color(0xFF43A047)..style = PaintingStyle.fill; + final whitePaint = Paint()..color = Colors.white..style = PaintingStyle.fill; + + final points1 = []; + final points2 = []; + + if (period <= 1) return; + + for (int i = 0; i < period; i++) { + final x = size.width * i / (period - 1); + points1.add(Offset(x, size.height * 0.3 + (i % 3) * 15)); + points2.add(Offset(x, size.height * 0.6 + (i % 4) * 10)); + } + + if (points1.length > 1) { + final path1 = Path()..moveTo(points1[0].dx, points1[0].dy); + for (var p in points1.skip(1)) path1.lineTo(p.dx, p.dy); + canvas.drawPath(path1, paint); + + final path2 = Path()..moveTo(points2[0].dx, points2[0].dy); + for (var p in points2.skip(1)) path2.lineTo(p.dx, p.dy); + canvas.drawPath(path2, paint2); + } + + for (var p in points1) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint1); } + for (var p in points2) { canvas.drawCircle(p, 4, whitePaint); canvas.drawCircle(p, 3, fillPaint2); } + } + + @override bool shouldRepaint(covariant CustomPainter oldDelegate) => oldDelegate is! _LineChartPainter || oldDelegate.period != period; +} diff --git a/health_app/lib/pages/diet/diet_capture_page.dart b/health_app/lib/pages/diet/diet_capture_page.dart index 5c3fc26..8246bcf 100644 --- a/health_app/lib/pages/diet/diet_capture_page.dart +++ b/health_app/lib/pages/diet/diet_capture_page.dart @@ -2,6 +2,7 @@ import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:image_picker/image_picker.dart'; +import '../../core/navigation_provider.dart'; final dietProvider = NotifierProvider(DietNotifier.new); @@ -432,7 +433,7 @@ class DietCapturePage extends ConsumerWidget { content: Text('饮食记录已保存 ✅'), backgroundColor: Color(0xFF635BFF), )); - Navigator.pop(context); + popRoute(ref); }, child: const Text('保存记录'), style: ElevatedButton.styleFrom( diff --git a/health_app/lib/pages/home/home_page.dart b/health_app/lib/pages/home/home_page.dart index afcf9ce..6720586 100644 --- a/health_app/lib/pages/home/home_page.dart +++ b/health_app/lib/pages/home/home_page.dart @@ -2,20 +2,16 @@ 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/api_client.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; import '../../providers/chat_provider.dart'; import '../../providers/data_providers.dart'; -import '../../widgets/agent_bar.dart'; import '../../widgets/health_drawer.dart'; import 'widgets/chat_messages_view.dart'; -/// 首页——主界面 class HomePage extends ConsumerStatefulWidget { const HomePage({super.key}); - @override - ConsumerState createState() => _HomePageState(); + @override ConsumerState createState() => _HomePageState(); } class _HomePageState extends ConsumerState { @@ -24,25 +20,12 @@ class _HomePageState extends ConsumerState { bool _taskCardsExpanded = true; bool _showExpandButton = false; - @override - void initState() { - super.initState(); - _scrollCtrl.addListener(_onScroll); - } - - @override - void dispose() { - _textCtrl.dispose(); - _scrollCtrl.dispose(); - super.dispose(); - } + @override void initState() { super.initState(); _scrollCtrl.addListener(_onScroll); } + @override void dispose() { _textCtrl.dispose(); _scrollCtrl.dispose(); super.dispose(); } void _onScroll() { - if (_scrollCtrl.offset > 50 && !_showExpandButton) { - setState(() => _showExpandButton = true); - } else if (_scrollCtrl.offset <= 50 && _showExpandButton) { - setState(() => _showExpandButton = false); - } + if (_scrollCtrl.offset > 50 && !_showExpandButton) setState(() => _showExpandButton = true); + else if (_scrollCtrl.offset <= 50 && _showExpandButton) setState(() => _showExpandButton = false); } void _sendMessage() { @@ -52,22 +35,25 @@ class _HomePageState extends ConsumerState { ref.read(chatProvider.notifier).sendMessage(text); } - @override - Widget build(BuildContext context) { + @override Widget build(BuildContext context) { final chatState = ref.watch(chatProvider); + final auth = ref.watch(authProvider); + final user = auth.user; + final bottomInset = MediaQuery.of(context).viewInsets.bottom; final selectedAgent = ref.watch(selectedAgentProvider); return Scaffold( drawer: const HealthDrawer(), + backgroundColor: const Color(0xFFF8F7FF), body: SafeArea( child: Stack(children: [ Column(children: [ - _buildHeader(context), + _buildHeader(user), if (_taskCardsExpanded) _buildTaskCards(), Expanded(child: ChatMessagesView(scrollCtrl: _scrollCtrl, messages: chatState.messages)), - _buildAgentPanel(context, selectedAgent), - const AgentBar(), - _buildInputBar(), + if (selectedAgent != null) _buildAgentPanel(context, selectedAgent), + _buildInputBar(context), + SizedBox(height: bottomInset > 0 ? bottomInset : 0), ]), _buildExpandButton(), ]), @@ -75,249 +61,93 @@ class _HomePageState extends ConsumerState { ); } - Widget _buildExpandButton() { - if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink(); - - return Positioned( - top: 60, - right: 16, - child: AnimatedOpacity( - opacity: _showExpandButton ? 1.0 : 0.0, - duration: const Duration(milliseconds: 300), - child: FloatingActionButton( - onPressed: () => setState(() => _taskCardsExpanded = true), - mini: true, - backgroundColor: const Color(0xFF635BFF), - child: const Icon(Icons.keyboard_arrow_down, size: 20), - ), - ), - ); - } - - Widget _buildHeader(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + Widget _buildHeader(dynamic user) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12), child: Row(children: [ - Builder(builder: (ctx) => IconButton( - icon: const Icon(Icons.menu, size: 24), - onPressed: () => Scaffold.of(ctx).openDrawer(), + Builder(builder: (ctx) => GestureDetector( + onTap: () => Scaffold.of(ctx).openDrawer(), + child: CircleAvatar(radius: 22, backgroundColor: const Color(0xFFEDEBFF), backgroundImage: user?.avatarUrl != null ? NetworkImage(user!.avatarUrl!) : null, child: user?.avatarUrl == null ? const Icon(Icons.person, size: 26, color: Color(0xFF635BFF)) : null), )), - const Spacer(), - Text('健康管家', style: Theme.of(context).textTheme.titleLarge), - const Spacer(), - const SizedBox(width: 48), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(mainAxisSize: MainAxisSize.min, children: [Icon(Icons.smart_toy_outlined, size: 18, color: const Color(0xFF635BFF)), const SizedBox(width: 4), Text('AI 健康管家', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Colors.grey[600]))]), + const SizedBox(height: 2), + Text('${_getGreeting()},${user?.name ?? '张三'}!', style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), + ])), + Icon(Icons.notifications_none, size: 24, color: Colors.grey[600]), ]), ); } - Widget _buildTaskCards() { - final latestHealth = ref.watch(latestHealthProvider); - - return latestHealth.when( - data: (data) { - final tasks = _getTaskCards(data); - if (tasks.isEmpty) return const SizedBox.shrink(); - - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(24), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], - ), - child: Column(children: [ - Row(children: [ - const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)), - const SizedBox(width: 8), - Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _taskCardsExpanded = false), - child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)), - ), - ]), - const SizedBox(height: 12), - Column(children: tasks), - ]), - ); - }, - loading: () => const SizedBox.shrink(), - error: (_, __) { - final tasks = _getTaskCards(const {}); - if (tasks.isEmpty) return const SizedBox.shrink(); - return Container( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: BorderRadius.circular(24), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))], - ), - child: Column(children: [ - Row(children: [ - const Icon(Icons.wb_sunny, size: 20, color: Color(0xFFFFB800)), - const SizedBox(width: 8), - Text(_getGreeting(), style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const Spacer(), - GestureDetector( - onTap: () => setState(() => _taskCardsExpanded = false), - child: const Icon(Icons.keyboard_arrow_down, size: 22, color: Color(0xFF999999)), - ), - ]), - const SizedBox(height: 12), - Column(children: tasks), - ]), - ); - }, - ); - } - String _getGreeting() { final hour = DateTime.now().hour; - if (hour < 6) return '夜深了'; if (hour < 9) return '早上好'; if (hour < 12) return '上午好'; - if (hour < 14) return '中午好'; if (hour < 18) return '下午好'; - if (hour < 22) return '晚上好'; - return '夜深了'; + return '晚上好'; } - List _getTaskCards(Map healthData) { - final cards = []; - - cards.add(_buildMedicationCard()); - cards.add(_buildExerciseCard()); - cards.add(_buildMeasurementCard()); - - final abnormalCards = _buildAbnormalCards(healthData); - cards.addAll(abnormalCards); - - final summaryCard = _buildSummaryCard(healthData); - if (summaryCard != null) cards.add(summaryCard); - - return cards; + Widget _buildExpandButton() { + if (!_showExpandButton || _taskCardsExpanded) return const SizedBox.shrink(); + return Positioned(top: 60, right: 16, child: AnimatedOpacity(opacity: _showExpandButton ? 1.0 : 0.0, duration: const Duration(milliseconds: 300), child: FloatingActionButton(onPressed: () => setState(() => _taskCardsExpanded = true), mini: true, backgroundColor: const Color(0xFF635BFF), child: const Icon(Icons.keyboard_arrow_down, size: 20)))); } - Widget _buildMedicationCard() { - return _buildTaskCard( - '💊', - '计划 8:00 吃 阿司匹林 100mg', - Icons.check_circle_outline, - () => _handleMedicationCheck(), - type: 'medication', + Widget _buildTaskCards() { + final latestHealth = ref.watch(latestHealthProvider); + + return latestHealth.when( + data: (data) => Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + 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(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [Text('今日任务', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Text('全部展开', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF))))]), + const SizedBox(height: 12), ..._getTodayTasks(data), + ]), + ), + loading: () => const SizedBox.shrink(), + error: (_, __) => Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + padding: const EdgeInsets.all(16), + 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(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Row(children: [const Text('今日任务', style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const Spacer(), GestureDetector(onTap: () => setState(() => _taskCardsExpanded = false), child: Text('全部展开', style: TextStyle(fontSize: 13, color: const Color(0xFF635BFF))))]), + const SizedBox(height: 12), ..._getTodayTasks({}), + ], + ), + ), ); } - Widget _buildExerciseCard() { - return _buildTaskCard( - '🏃', - '今日待运动:散步 30 分钟', - Icons.check_circle_outline, - () => _handleExerciseCheck(), - type: 'exercise', - ); + List _getTodayTasks(Map 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), + ]; } - Widget _buildMeasurementCard() { - return _buildTaskCard( - '🩺', - '今日待测量:血压', - Icons.arrow_forward_ios, - () => _textCtrl.text = '血压 ', - type: 'measurement', - ); - } - - List _buildAbnormalCards(Map healthData) { - final cards = []; - + List _buildAbnormalRows(Map healthData) { + final rows = []; final bp = healthData['BloodPressure']; - if (bp != null && bp is Map) { - final systolic = bp['systolic']; - final diastolic = bp['diastolic']; - if (systolic != null && systolic >= 140) { - cards.add(_buildTaskCard( - '⚠️', - '昨日血压 ${systolic}/${diastolic ?? '--'},偏高', - Icons.arrow_forward_ios, - () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), - type: 'warning', - highlight: true, - )); - } - } - - final hr = healthData['HeartRate']; - if (hr != null && hr is Map) { - final value = hr['value']; - if (value != null && (value > 100 || value < 60)) { - cards.add(_buildTaskCard( - '⚠️', - '昨日心率 $value,${value > 100 ? '偏高' : '偏低'}', - Icons.arrow_forward_ios, - () => pushRoute(ref, 'trend', params: {'type': 'heart_rate'}), - type: 'warning', - highlight: true, - )); - } - } - - return cards; + 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? _buildSummaryCard(Map healthData) { - final values = []; - - final bp = healthData['BloodPressure']; - if (bp != null && bp is Map) { - final sys = bp['systolic']; - final dia = bp['diastolic']; - if (sys != null && dia != null) values.add('血压 $sys/$dia'); - } - - final hr = healthData['HeartRate']; - if (hr != null && hr is Map && hr['value'] != null) { - values.add('心率 ${hr['value']}'); - } - - final glucose = healthData['Glucose']; - if (glucose != null && glucose is Map && glucose['value'] != null) { - values.add('血糖 ${glucose['value']}'); - } - - if (values.isEmpty) return null; - - return _buildTaskCard( - '📊', - '今日已记录:${values.join('、')}', - Icons.arrow_forward_ios, - () => pushRoute(ref, 'trend', params: {'type': 'blood_pressure'}), - type: 'summary', - ); - } - - Widget _buildTaskCard(String icon, String text, IconData actionIcon, VoidCallback onTap, {String type = '', bool highlight = false}) { + 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.symmetric(vertical: 6), - child: Container( - padding: const EdgeInsets.symmetric(vertical: 8), - decoration: highlight ? BoxDecoration( - color: const Color(0xFFFDF2F2), - borderRadius: BorderRadius.circular(12), - ) : null, + padding: const EdgeInsets.only(bottom: 12), + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, child: Row(children: [ - Text(icon, style: const TextStyle(fontSize: 20)), - const SizedBox(width: 10), - Expanded(child: Text(text, style: TextStyle( - fontSize: 14, - color: highlight ? const Color(0xFFDC2626) : const Color(0xFF333333), - ))), - GestureDetector( - onTap: onTap, - child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)), - ), + Container(width: 32, height: 32, decoration: BoxDecoration(color: const Color(0xFFF5F3FF), borderRadius: BorderRadius.circular(8)), child: Icon(icon, size: 16, color: const Color(0xFF635BFF))), + const SizedBox(width: 10), Expanded(child: Text(label, style: const TextStyle(fontSize: 14, color: Color(0xFF333333)))), + Icon(icons[status], size: 20, color: colors[status] ?? Colors.grey), ]), ), ); @@ -325,211 +155,73 @@ class _HomePageState extends ConsumerState { void _handleMedicationCheck() async { await ref.read(medicationServiceProvider).confirm(''); - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('已记录服药 ✅'), - backgroundColor: Color(0xFF635BFF), - duration: Duration(seconds: 2), - )); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text('已记录服药 ✅'), backgroundColor: Color(0xFF635BFF))); } - void _handleExerciseCheck() async { - ScaffoldMessenger.of(context).showSnackBar(const SnackBar( - content: Text('已完成运动 ✅'), - backgroundColor: Color(0xFF635BFF), - duration: Duration(seconds: 2), - )); - } + 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 _buildAgentPanel(BuildContext context, ActiveAgent? agent) { - if (agent == null) return const SizedBox.shrink(); - - return AnimatedContainer( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFFFEFEFF), - borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), - boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(20), blurRadius: 12, offset: const Offset(0, -4))], - ), - child: Column(mainAxisSize: MainAxisSize.min, children: [ - _buildAgentPanelHeader(agent), - const SizedBox(height: 12), - ..._getAgentButtons(agent), - ]), - ); - } - - Widget _buildAgentPanelHeader(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 分钟"', - }; - - return Column(children: [ + return AnimatedContainer(duration: const Duration(milliseconds: 300), curve: Curves.easeInOut, padding: const EdgeInsets.all(16), decoration: BoxDecoration(color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(15), blurRadius: 12, offset: const Offset(0, -4))]), child: Column(mainAxisSize: MainAxisSize.min, children: [ Text(titles[agent] ?? '', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), - const SizedBox(height: 4), - Text(tips[agent] ?? '', style: const TextStyle(fontSize: 12, color: Color(0xFF999999))), - ]); + const SizedBox(height: 4), Text(tips[agent] ?? '', style: TextStyle(fontSize: 12, color: Colors.grey[500])), + const SizedBox(height: 12), Wrap(spacing: 8, runSpacing: 8, children: _getAgentButtons(agent)), + ])); } List _getAgentButtons(ActiveAgent agent) { - final buttons = []; - if (agent == ActiveAgent.health) { - buttons.add(_panelBtn('手动录入血压', Icons.favorite)); - buttons.add(_panelBtn('手动录入血糖', Icons.bloodtype)); - buttons.add(_panelBtn('手动录入心率', Icons.monitor_heart)); - buttons.add(_panelBtn('手动录入血氧', Icons.air)); - buttons.add(_panelBtn('手动录入体重', Icons.monitor_weight)); - } else if (agent == ActiveAgent.diet) { - buttons.add(_panelBtn('拍照', Icons.camera_alt)); - buttons.add(_panelBtn('上传照片', Icons.photo_library)); - } else if (agent == ActiveAgent.medication) { - buttons.add(_panelBtn('用药管理', Icons.medication)); - buttons.add(_panelBtn('用药提醒', Icons.alarm)); - } else if (agent == ActiveAgent.consultation) { - buttons.add(_panelBtn('找医生', Icons.person_search)); - } else if (agent == ActiveAgent.exercise) { - buttons.add(_panelBtn('查看本周计划', Icons.calendar_view_week)); - buttons.add(_panelBtn('创建新计划', Icons.add_circle_outline)); + 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 []; } - return buttons; } - Widget _panelBtn(String label, IconData icon) { - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: SizedBox( - width: double.infinity, - child: ElevatedButton.icon( - onPressed: () => _onAgentAction(label), - icon: Icon(icon, size: 18), - label: Text(label, style: const TextStyle(fontSize: 14)), - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFF5F3FF), - foregroundColor: const Color(0xFF635BFF), - elevation: 0, - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), - padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16), - ), - ), - ), + Widget _agentBtn(String label, IconData icon) { + return ElevatedButton.icon( + onPressed: () => _onAgentAction(label), + icon: Icon(icon, size: 16), + label: Text(label, style: const TextStyle(fontSize: 13)), + style: ElevatedButton.styleFrom(backgroundColor: const Color(0xFFF5F3FF), foregroundColor: const Color(0xFF635BFF), elevation: 0, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(18)), padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 14)), ); } void _onAgentAction(String label) { switch (label) { - case '拍照': - pushRoute(ref, 'dietCapture'); - break; - case '上传照片': - pushRoute(ref, 'dietCapture'); - break; - case '手动录入血压': - _textCtrl.text = '血压 '; - break; - case '手动录入血糖': - _textCtrl.text = '血糖 '; - break; - case '手动录入心率': - _textCtrl.text = '心率 '; - break; - case '手动录入血氧': - _textCtrl.text = '血氧 '; - break; - case '手动录入体重': - _textCtrl.text = '体重 '; - break; - case '用药管理': - pushRoute(ref, 'medications'); - break; - case '找医生': - pushRoute(ref, 'doctors'); - break; - case '查看本周计划': - pushRoute(ref, 'exercisePlan'); - break; - case '创建新计划': - pushRoute(ref, 'exercisePlan'); - break; + 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'); } } Future _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 = '[图片已上传] $baseUrl/api/files/${picked.path.split('/').last}'; - setState(() {}); - } + if (picked != null) { final token = await ref.read(apiClientProvider).accessToken; if (token == null) return; _textCtrl.text = '[图片已上传]'; setState(() {}); } } void _showAttachmentPicker(BuildContext context) { - showModalBottomSheet( - context: context, - builder: (ctx) => SafeArea( - child: Wrap( - children: [ - ListTile( - leading: const Icon(Icons.camera_alt), - title: const Text('拍照'), - onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }, - ), - ListTile( - leading: const Icon(Icons.photo_library), - title: const Text('从相册选'), - onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }, - ), - ListTile( - leading: const Icon(Icons.attach_file), - title: const Text('传文件'), - onTap: () async { - Navigator.pop(ctx); - final result = await FilePicker.platform.pickFiles(); - if (result != null && result.files.isNotEmpty) { - _textCtrl.text = '[文件已选择] ${result.files.first.name}'; - setState(() {}); - } - }, - ), - ], - ), - ), - ); + showModalBottomSheet(context: context, builder: (ctx) => SafeArea(child: Wrap(children: [ + ListTile(leading: const Icon(Icons.camera_alt), title: const Text('拍照'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.camera); }), + ListTile(leading: const Icon(Icons.photo_library), title: const Text('从相册选'), onTap: () { Navigator.pop(ctx); _pickImage(ImageSource.gallery); }), + ListTile(leading: const Icon(Icons.attach_file), title: const Text('传文件'), onTap: () async { Navigator.pop(ctx); final result = await FilePicker.platform.pickFiles(); if (result != null && result.files.isNotEmpty) { _textCtrl.text = '[文件已选择] ${result.files.first.name}'; setState(() {}); }}), + ]))); } - Widget _buildInputBar() { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: BoxDecoration( - color: Colors.white, - border: Border(top: BorderSide(color: Colors.grey.shade200)), - ), - child: Row(children: [ - IconButton(icon: const Icon(Icons.attach_file, size: 24, color: Color(0xFF666666)), onPressed: () => _showAttachmentPicker(context)), - Expanded( - child: TextField( - controller: _textCtrl, - decoration: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none), - onSubmitted: (_) => _sendMessage(), - ), - ), - IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage), - ]), - ); + Widget _buildInputBar(BuildContext context) { + return Container(padding: const EdgeInsets.symmetric(horizontal: 12, 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, decoration: InputDecoration(hintText: '输入你想说的...', contentPadding: const EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none), onSubmitted: (_) => _sendMessage())), + IconButton(icon: const Icon(Icons.send, size: 24, color: Color(0xFF635BFF)), onPressed: _sendMessage), + ])); } } diff --git a/health_app/lib/pages/medication/medication_edit_page.dart b/health_app/lib/pages/medication/medication_edit_page.dart new file mode 100644 index 0000000..22e65bb --- /dev/null +++ b/health_app/lib/pages/medication/medication_edit_page.dart @@ -0,0 +1,97 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class MedicationEditPage extends ConsumerStatefulWidget { + final String? medicationId; + const MedicationEditPage({super.key, this.medicationId}); + + @override ConsumerState createState() => _MedicationEditPageState(); +} + +class _MedicationEditPageState extends ConsumerState { + final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片'); + final _dosageCtrl = TextEditingController(text: '100mg'); + String _frequency = '每日1次'; + String _time = '08:00'; + DateTime _startDate = DateTime.now(); + String _duration = '长期服用'; + + @override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); super.dispose(); } + + @override Widget build(BuildContext context) { + return Scaffold( + backgroundColor: Colors.white, + 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)), + 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)), + ), + ], + ), + 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), + ]), + ), + ); + } + + void _pickFrequency() async { + final options = ['每日1次', '每日2次', '每日3次', '每周1次', '按需服用']; + final selected = await showModalBottomSheet( + context: context, + builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())), + ); + if (selected != null && mounted) setState(() => _frequency = selected); + } + + 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( + context: context, + builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())), + ); + if (selected != null && mounted) setState(() => _duration = selected); + } +} diff --git a/health_app/lib/pages/medication/medication_list_page.dart b/health_app/lib/pages/medication/medication_list_page.dart index a3daf69..745c90b 100644 --- a/health_app/lib/pages/medication/medication_list_page.dart +++ b/health_app/lib/pages/medication/medication_list_page.dart @@ -3,78 +3,160 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/data_providers.dart'; -/// 用药列表页 class MedicationListPage extends ConsumerWidget { const MedicationListPage({super.key}); + @override Widget build(BuildContext context, WidgetRef ref) { final meds = ref.watch(medicationListProvider); return Scaffold( - appBar: AppBar(title: const Text('我的用药')), - body: meds.when( - data: (list) { - if (list.isEmpty) return _empty(context); - return ListView.builder( - itemCount: list.length, - itemBuilder: (ctx, i) { - final m = list[i]; - final times = (m['timeOfDay'] as List?)?.cast() ?? []; - return Card( - margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), - child: ListTile( - leading: const Icon(Icons.medication, color: Color(0xFF635BFF)), - title: Text('${m['name']} ${m['dosage'] ?? ''}', style: const TextStyle(fontSize: 16)), - subtitle: Text('每天 ${times.join("、")}', style: const TextStyle(fontSize: 14, color: Color(0xFF666666))), - trailing: IconButton(icon: const Icon(Icons.check_circle_outline, color: Color(0xFF43A047)), onPressed: () async { - await ref.read(medicationServiceProvider).confirm(m['id']); - ref.invalidate(medicationListProvider); - }), - ), - ); - }, - ); - }, - loading: () => const Center(child: CircularProgressIndicator()), - error: (_, _) => _empty(context), + backgroundColor: const Color(0xFFF8F7FF), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + title: const Text('我的用药', style: TextStyle(color: Color(0xFF1A1A1A), fontWeight: FontWeight.w600)), + centerTitle: true, + actions: [ + TextButton( + onPressed: () => pushRoute(ref, 'medicationEdit'), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.add_circle_outline, size: 18, color: Color(0xFF635BFF)), + const SizedBox(width: 4), + const Text('添加新药', style: TextStyle(color: Color(0xFF635BFF), fontSize: 14)), + ]), + ), + ], ), - floatingActionButton: FloatingActionButton.extended( - onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); }, - icon: const Icon(Icons.add), label: const Text('添加药品'), + body: Column(children: [ + Container(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), child: Row(children: [ + _TabChip(label: '全部', active: true), + const SizedBox(width: 8), + _TabChip(label: '服用中'), + const SizedBox(width: 8), + _TabChip(label: '已停药'), + ])), + Expanded(child: meds.when( + data: (list) { + if (list.isEmpty) return _empty(context); + return ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: list.length + 1, + itemBuilder: (ctx, i) { + if (i == list.length) return const SizedBox(height: 80); + final m = list[i]; + return _MedicationCard(data: m); + }, + ); + }, + loading: () => const Center(child: CircularProgressIndicator(color: Color(0xFF635BFF))), + error: (_, __) => _empty(context), + )), + _buildReminderBar(), + ]), + ); + } + + Widget _buildReminderBar() { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(16), + decoration: BoxDecoration(color: Colors.white, boxShadow: [BoxShadow(color: Colors.grey.withAlpha(30), blurRadius: 8)]), + child: Row(children: [ + Container( + width: 40, + height: 40, + decoration: BoxDecoration( + color: const Color(0xFFEDEBFF), + borderRadius: BorderRadius.circular(20), + border: Border.all(color: const Color(0xFF635BFF).withAlpha(50)), + ), + child: const Icon(Icons.notifications_active_outlined, size: 20, color: Color(0xFF635BFF)), + ), + const SizedBox(width: 12), + const Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('用药提醒已开启', style: TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1A1A1A))), + Text('按时服药,守护心脏健康一天', style: TextStyle(fontSize: 12, color: Color(0xFF9E9E9E))), + ])), + const Icon(Icons.chevron_right, size: 18, color: Color(0xFFBDBDBD)), + ]), + ); + } + + Widget _empty(BuildContext context) { + return Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.medication_outlined, size: 64, color: Color(0xFFE0E0E0)), + const SizedBox(height: 12), + const Text('暂无用药计划', style: TextStyle(fontSize: 15, color: Color(0xFF9E9E9E))), + const SizedBox(height: 8), + const Text('可通过 AI 对话或手动添加', style: TextStyle(fontSize: 13, color: Color(0xFFBDBDBD))), + ])); + } +} + +class _TabChip extends StatelessWidget { + final String label; + final bool active; + const _TabChip({required this.label, this.active = false}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 7), + decoration: BoxDecoration( + color: active ? const Color(0xFF635BFF) : Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: active ? const Color(0xFF635BFF) : const Color(0xFFE0E0E0)), + ), + child: Text( + label, + style: TextStyle( + fontSize: 13, + fontWeight: active ? FontWeight.w600 : FontWeight.normal, + color: active ? Colors.white : const Color(0xFF757575), + ), ), ); } - Widget _empty(BuildContext context) => Center(child: Column(mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.medication, size: 64, color: Colors.grey[300]), - const SizedBox(height: 12), Text('暂无用药计划', style: Theme.of(context).textTheme.bodyMedium), - const SizedBox(height: 8), Text('可通过 AI 对话或手动添加', style: Theme.of(context).textTheme.labelMedium), - ])); } -/// 编辑用药页 -class MedicationEditPage extends ConsumerStatefulWidget { - final String? id; - const MedicationEditPage({super.key, this.id}); - @override ConsumerState createState() => _MedicationEditPageState(); -} -class _MedicationEditPageState extends ConsumerState { - final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController(); - @override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); } +class _MedicationCard extends StatelessWidget { + final Map data; + const _MedicationCard({required this.data}); - Future _save() async { - await ref.read(medicationServiceProvider).create({ - 'name': _nameCtrl.text, 'dosage': _dosageCtrl.text, - 'frequency': 'Daily', 'timeOfDay': [if (_timeCtrl.text.isNotEmpty) _timeCtrl.text], - 'source': 'Manual', 'startDate': DateTime.now().toIso8601String().substring(0, 10), - }); - popRoute(ref); + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.only(bottom: 12), + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 4, offset: const Offset(0, 2))], + ), + child: Row(children: [ + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFFF5F3FF), + borderRadius: BorderRadius.circular(14), + ), + child: const Center(child: Text('💊', style: TextStyle(fontSize: 24))), + ), + const SizedBox(width: 12), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text('${data['name'] ?? ''}', style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + const SizedBox(height: 4), + Text('${data['dosage'] ?? ''} · 每日1次', style: const TextStyle(fontSize: 13, color: Color(0xFF9E9E9E))), + const SizedBox(height: 2), + Text('08:00 · 剩余 1 片', style: const TextStyle(fontSize: 12, color: Color(0xFFBDBDBD))), + ])), + Container( + width: 28, + height: 28, + decoration: const BoxDecoration(color: Color(0xFFDCFCE7), shape: BoxShape.circle), + child: const Icon(Icons.check, size: 16, color: Color(0xFF43A047)), + ), + ]), + ); } - - @override Widget build(BuildContext context) => Scaffold( - appBar: AppBar(title: const Text('添加药品')), - body: ListView(padding: const EdgeInsets.all(16), children: [ - TextField(controller: _nameCtrl, decoration: const InputDecoration(labelText: '药品名称', hintText: '如:阿司匹林')), - const SizedBox(height: 16), TextField(controller: _dosageCtrl, decoration: const InputDecoration(labelText: '剂量', hintText: '如:100mg')), - const SizedBox(height: 16), TextField(controller: _timeCtrl, decoration: const InputDecoration(labelText: '服药时间', hintText: '如:08:00:00')), - const SizedBox(height: 32), SizedBox(width: double.infinity, height: 48, child: ElevatedButton(onPressed: _save, child: const Text('保存'))), - ]), - ); } diff --git a/health_app/lib/pages/profile/profile_detail_page.dart b/health_app/lib/pages/profile/profile_detail_page.dart new file mode 100644 index 0000000..36cde32 --- /dev/null +++ b/health_app/lib/pages/profile/profile_detail_page.dart @@ -0,0 +1,68 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/navigation_provider.dart'; +import '../../providers/data_providers.dart'; + +class ProfileDetailPage extends ConsumerWidget { + const ProfileDetailPage({super.key}); + + @override Widget build(BuildContext context, WidgetRef ref) { + final latestHealth = ref.watch(latestHealthProvider); + + return Scaffold( + backgroundColor: const Color(0xFFF8F7FF), + appBar: AppBar( + backgroundColor: Colors.white, + elevation: 0, + leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), + title: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.person_outline, size: 20, color: Colors.grey[600]), + const SizedBox(width: 6), + Text('健康档案', style: TextStyle(color: Colors.grey[800], fontWeight: FontWeight.w600)), + ]), + 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('退出档案')))]))), + ); + } + + 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 _buildHealthOverview(AsyncValue> 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))]), + 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()), + ]), + ); + } + + Widget _buildMetricsList(Map data) { + return Column(children: [_metricRow(Icons.favorite, '血压', _formatBP(data['BloodPressure'])), const Divider(), _metricRow(Icons.monitor_heart, '心率', _formatMetric(data['HeartRate'], '次/分')), const Divider(), _metricRow(Icons.bloodtype, '血糖', _formatMetric(data['Glucose'], 'mmol/L')), const Divider(), _metricRow(Icons.air, '血氧', _formatMetric(data['SpO2'], '%')), const Divider(), _metricRow(Icons.monitor_weight, '体重', _formatMetric(data['Weight'], 'kg'))]); + } + + Widget _buildMetricsEmpty() { + return Column(children: [_metricRow(Icons.favorite, '血压', '--/--'), const Divider(), _metricRow(Icons.monitor_heart, '心率', '-- 次/分'), const Divider(), _metricRow(Icons.bloodtype, '血糖', '-- mmol/L'), const Divider(), _metricRow(Icons.air, '血氧', '-- %'), const Divider(), _metricRow(Icons.monitor_weight, '体重', '-- kg')]); + } + + 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 _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()])); + } + + Widget _historyItem(Map item) { + final isNormal = item['status'] == 'normal'; + return Container(margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration(borderRadius: BorderRadius.circular(12)), child: Row(children: [Text(item['date']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF9E9E9E))), const SizedBox(width: 8), Expanded(child: Text(item['label']?.toString() ?? '', style: const TextStyle(fontSize: 14, color: Color(0xFF333333)))), Text(item['value']?.toString() ?? '', style: const TextStyle(fontSize: 15, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), const SizedBox(width: 8), Container(padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration(color: isNormal ? const Color(0xFF43A047).withAlpha(20) : const Color(0xFFFF9800).withAlpha(20), borderRadius: BorderRadius.circular(10)), child: Text(isNormal ? '正常' : '偏高', style: TextStyle(fontSize: 11, color: isNormal ? const Color(0xFF43A047) : const Color(0xFFFF9800))))])); + } +} diff --git a/health_app/lib/pages/profile/profile_page.dart b/health_app/lib/pages/profile/profile_page.dart index 0fe8be7..789c392 100644 --- a/health_app/lib/pages/profile/profile_page.dart +++ b/health_app/lib/pages/profile/profile_page.dart @@ -3,67 +3,98 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; -/// 个人中心页面 class ProfilePage extends ConsumerWidget { const ProfilePage({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) { + @override Widget build(BuildContext context, WidgetRef ref) { final auth = ref.watch(authProvider); final user = auth.user; return Scaffold( - appBar: AppBar(title: const Text('个人中心')), - body: ListView( - children: [ - // 头像区 - Container( - padding: const EdgeInsets.all(24), - color: const Color(0xFFEDEBFF), - child: Column( - children: [ - CircleAvatar( - radius: 40, - backgroundColor: const Color(0xFF635BFF), - child: Text( - (user?.name ?? '?')[0], - style: const TextStyle(fontSize: 32, color: Colors.white), - ), - ), - const SizedBox(height: 12), - Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge), - const SizedBox(height: 4), - Text(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium), - ], + backgroundColor: const Color(0xFFF8F7FF), + 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'), + 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))), + ]), ), + const SizedBox(width: 16), + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + Text(user?.name ?? '张三', style: const TextStyle(fontSize: 22, fontWeight: FontWeight.bold, color: Color(0xFF1A1A1A))), + const SizedBox(height: 4), + Text('42岁', style: TextStyle(fontSize: 14, color: Colors.grey[500])), + ])), + Icon(Icons.chevron_right, size: 24, color: Colors.grey[400]), + ]), + ])), + const SizedBox(height: 12), + _MenuItem(icon: Icons.folder_shared, title: '健康档案', onTap: () => pushRoute(ref, 'profile')), + _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: '意见反馈'), + _MenuItem(icon: Icons.info_outline, title: '关于我们'), + const SizedBox(height: 40), + GestureDetector( + onTap: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退出登录'), + content: const Text('确定退出?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')), + ], + ), + ); + if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)), + child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)), ), - const SizedBox(height: 8), - _MenuItem(icon: Icons.person, title: '编辑资料', onTap: () => pushRoute(ref, 'profileEdit')), - _MenuItem(icon: Icons.folder, title: '健康档案', onTap: () => pushRoute(ref, 'healthArchive')), - _MenuItem(icon: Icons.devices, title: '设备管理', onTap: () {}), - const Divider(), - _MenuItem(icon: Icons.settings, title: '设置', onTap: () => pushRoute(ref, 'settings')), - _MenuItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})), - const Divider(), - _MenuItem( - icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), - onTap: () async { - final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( - title: const Text('退出登录'), content: const Text('确定退出?'), - actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), - TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))])); - if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } - }, - ), - ], - ), + ), + ]))), ); } } class _MenuItem extends StatelessWidget { - final IconData icon; final String title; final VoidCallback onTap; final Color? textColor; - const _MenuItem({required this.icon, required this.title, required this.onTap, this.textColor}); - @override - Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor ?? const Color(0xFF1A1A1A))), trailing: const Icon(Icons.chevron_right, size: 20), onTap: onTap); + final IconData icon; + final String title; + final String? trailing; + final VoidCallback? onTap; + + const _MenuItem({required this.icon, required this.title, this.trailing, this.onTap}); + + @override Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2), + 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))), + 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]))], + if (trailing == null || trailing!.isEmpty) const Spacer(), + Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]), + ]), + ), + ); + } } diff --git a/health_app/lib/pages/report/ai_analysis_page.dart b/health_app/lib/pages/report/ai_analysis_page.dart new file mode 100644 index 0000000..76638c3 --- /dev/null +++ b/health_app/lib/pages/report/ai_analysis_page.dart @@ -0,0 +1,41 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +class AiAnalysisPage extends ConsumerWidget { + const AiAnalysisPage({super.key}); + + @override Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: Colors.white, + appBar: AppBar(backgroundColor: Colors.white, elevation: 0, leading: IconButton(icon: const Icon(Icons.chevron_left), onPressed: () => Navigator.pop(context)), title: _buildTitle(), centerTitle: true, actions: [IconButton(icon: const Icon(Icons.more_vert), color: const Color(0xFF666666), onPressed: () {})]), + body: SingleChildScrollView(padding: const EdgeInsets.all(16), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [_buildReportPreview(), const SizedBox(height: 20), _buildIndicators(), const SizedBox(height: 24), _buildAiInterpretation(), const SizedBox(height: 24), _buildDoctorAdvice(), const SizedBox(height: 24), _buildHealthTips()])), + ); + } + + 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))])), + 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 _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()]); + } + + Widget _indicatorCard(Map item) { + final isNormal = item['status'] == 'normal'; + 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 _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 _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 _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()]); +} diff --git a/health_app/lib/pages/settings/notification_prefs_page.dart b/health_app/lib/pages/settings/notification_prefs_page.dart new file mode 100644 index 0000000..31a6cda --- /dev/null +++ b/health_app/lib/pages/settings/notification_prefs_page.dart @@ -0,0 +1,216 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../../core/navigation_provider.dart'; + +// ── 通知偏好状态 ── + +final notificationPrefsProvider = NotifierProvider>( + NotificationPrefsNotifier.new, +); + +class NotificationPrefsNotifier extends Notifier> { + @override + Map build() { + // TODO: 从 SQLite 读取持久化值,此处先用默认值 + return { + 'medication': true, + 'healthAlert': true, + 'followUp': true, + 'aiReply': false, + 'dndEnabled': false, + 'pushEnabled': true, + 'dndStart': false, // 占位:实际用 TimeOfDay + 'dndEnd': false, + }; + } + + void toggle(String key) { + state = {...state, key: !state[key]!}; + // TODO: 持久化到 SQLite + } + + void setDndStart(TimeOfDay time) { + state = {...state, 'dndStart': true}; // 简化存储 + } + + void setDndEnd(TimeOfDay time) { + state = {...state, 'dndEnd': true}; + } +} + +// ── 页面 ── + +class NotificationPrefsPage extends ConsumerWidget { + const NotificationPrefsPage({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final prefs = ref.watch(notificationPrefsProvider); + final dndOn = prefs['dndEnabled'] ?? false; + + return Scaffold( + backgroundColor: const Color(0xFFF8F7FF), + appBar: AppBar( + backgroundColor: Colors.transparent, + elevation: 0, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new, color: Color(0xFF1A1A1A)), + onPressed: () => popRoute(ref), + ), + title: const Text('消息通知', style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600, color: Color(0xFF1A1A1A))), + centerTitle: true, + ), + body: SingleChildScrollView( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── 推送总开关 ── + _SectionTitle(title: '推送通知'), + _SwitchTile( + title: '允许推送通知', + subtitle: '关闭后将不再收到任何系统推送', + value: prefs['pushEnabled'] ?? true, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('pushEnabled'), + ), + const SizedBox(height: 24), + + // ── 各类通知开关 ── + _SectionTitle(title: '通知类型'), + _SwitchTile( + icon: Icons.medication_rounded, + iconBg: const Color(0xFFFFF3E0), + iconColor: const Color(0xFFFF9800), + title: '用药提醒', + subtitle: '服药时间到达时提醒您', + value: prefs['medication'] ?? true, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('medication'), + ), + _SwitchTile( + icon: Icons.warning_amber_rounded, + iconBg: const Color(0xFFFFEBEE), + iconColor: const Color(0xFFE53935), + title: '健康异常提醒', + subtitle: '检测到数据异常时及时通知', + value: prefs['healthAlert'] ?? true, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('healthAlert'), + ), + _SwitchTile( + icon: Icons.event_available_rounded, + iconBg: const Color(0xFFE8F5E9), + iconColor: const Color(0xFF4CAF50), + title: '复查日期提醒', + subtitle: '复查日前一天提醒您预约', + value: prefs['followUp'] ?? true, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('followUp'), + ), + _SwitchTile( + icon: Icons.smart_toy_outlined, + iconBg: const Color(0xFFF3E5F5), + iconColor: const Color(0xFF635BFF), + title: 'AI 回复通知', + subtitle: 'AI 助手回复时发送通知', + value: prefs['aiReply'] ?? false, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('aiReply'), + ), + const SizedBox(height: 24), + + // ── 免打扰时段 ── + _SectionTitle(title: '免打扰时段'), + _SwitchTile( + title: '开启免打扰模式', + subtitle: dndOn ? '22:00 - 08:00 期间静音' : '关闭后全天接收通知', + value: dndOn, + onChanged: (v) => ref.read(notificationPrefsProvider.notifier).toggle('dndEnabled'), + ), + if (dndOn) ...[ + Container( + margin: const EdgeInsets.symmetric(horizontal: 4), + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + Expanded(child: _TimeButton(label: '开始', time: '22:00', onTap: () async { + final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 22, minute: 0)); + if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndStart(picked); + })), + Padding(padding: const EdgeInsets.symmetric(horizontal: 12), child: Text('~', style: TextStyle(fontSize: 16, color: Colors.grey[400]))), + Expanded(child: _TimeButton(label: '结束', time: '08:00', onTap: () async { + final picked = await showTimePicker(context: context, initialTime: const TimeOfDay(hour: 8, minute: 0)); + if (picked != null && context.mounted) ref.read(notificationPrefsProvider.notifier).setDndEnd(picked); + })), + ]), + ), + const SizedBox(height: 8), + ], + const SizedBox(height: 40), + ], + ), + ), + ); + } +} + +// ── 子组件 ── + +class _SectionTitle extends StatelessWidget { + final String title; + const _SectionTitle({required this.title}); + @override + Widget build(BuildContext context) { + return Padding(padding: const EdgeInsets.only(left: 4, bottom: 10), child: Text(title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Color(0xFF999999)))); + } +} + +class _SwitchTile extends StatelessWidget { + final IconData? icon; + final Color? iconBg; + final Color? iconColor; + final String title; + final String? subtitle; + final bool value; + final ValueChanged onChanged; + + const _SwitchTile({ + this.icon, this.iconBg, this.iconColor, + required this.title, + this.subtitle, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Container( + margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 3), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), + decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(12)), + child: Row(children: [ + if (icon != null) ...[ + Container(width: 38, height: 38, decoration: BoxDecoration(color: iconBg, borderRadius: BorderRadius.circular(10)), child: Icon(icon, size: 20, color: iconColor)), + const SizedBox(width: 12), + ], + Expanded(child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ + 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)), + ]), + ); + } +} + +class _TimeButton extends StatelessWidget { + final String label; + final String time; + final VoidCallback onTap; + const _TimeButton({required this.label, required this.time, required this.onTap}); + + @override + Widget build(BuildContext context) { + 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)))]), + )); + } +} diff --git a/health_app/lib/pages/settings/settings_pages.dart b/health_app/lib/pages/settings/settings_pages.dart index a8ac35a..5e2cb0d 100644 --- a/health_app/lib/pages/settings/settings_pages.dart +++ b/health_app/lib/pages/settings/settings_pages.dart @@ -3,65 +3,77 @@ import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../../core/navigation_provider.dart'; import '../../providers/auth_provider.dart'; -/// 设置页 class SettingsPage extends ConsumerWidget { const SettingsPage({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - appBar: AppBar(title: const Text('设置')), - body: ListView(children: [ - _SetItem(icon: Icons.shield, title: '隐私保护中心', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'privacy'})), - _SetItem(icon: Icons.notifications, title: '通知偏好', onTap: () => pushRoute(ref, 'notificationPrefs')), - _SetItem(icon: Icons.text_fields, title: '字体大小', trailing: _FontSlider()), - _SetItem(icon: Icons.article, title: '协议与公告', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'terms'})), - _SetItem(icon: Icons.info, title: '关于', onTap: () => pushRoute(ref, 'staticText', params: {'type': 'about'})), - const Divider(), - _SetItem(icon: Icons.logout, title: '退出登录', textColor: const Color(0xFFE53935), onTap: () async { - final ok = await showDialog(context: context, builder: (ctx) => AlertDialog( - title: const Text('退出登录'), content: const Text('确定退出?'), - actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))])); - if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } - }), - ]), - ); + + @override Widget build(BuildContext context, WidgetRef ref) { + return Scaffold( + backgroundColor: const Color(0xFFF8F7FF), + 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: '隐私协议'), + const SizedBox(height: 30), + GestureDetector( + onTap: () async { + final ok = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('退出登录'), + content: const Text('确定退出?'), + actions: [ + TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), + TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')), + ], + ), + ); + if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); } + }, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24), + height: 50, + alignment: Alignment.center, + decoration: BoxDecoration(border: Border.all(color: const Color(0xFFE53935)), borderRadius: BorderRadius.circular(25)), + child: const Text('退出登录', style: TextStyle(fontSize: 16, color: Color(0xFFE53935), fontWeight: FontWeight.w500)), + ), + ), + ]))), + ); + } } class _SetItem extends StatelessWidget { - final IconData icon; final String title; final VoidCallback? onTap; final Widget? trailing; final Color? textColor; - const _SetItem({required this.icon, required this.title, this.onTap, this.trailing, this.textColor}); - @override - Widget build(BuildContext context) => ListTile(leading: Icon(icon, color: const Color(0xFF666666)), title: Text(title, style: TextStyle(fontSize: 16, color: textColor)), trailing: trailing ?? const Icon(Icons.chevron_right, size: 20), onTap: onTap); -} + final IconData icon; + final String title; + final String? subtitle; + final String? trailingText; + final VoidCallback? onTap; -class _FontSlider extends StatefulWidget { - @override State<_FontSlider> createState() => _FontSliderState(); -} -class _FontSliderState extends State<_FontSlider> { - double _value = 1.0; - @override Widget build(BuildContext context) => SizedBox(width: 120, child: Slider(value: _value, min: 0.8, max: 1.6, divisions: 8, label: '${_value.toStringAsFixed(1)}x', onChanged: (v) => setState(() => _value = v))); -} + const _SetItem({required this.icon, required this.title, this.subtitle, this.trailingText, this.onTap}); -/// 通知偏好页 -class NotificationPrefsPage extends ConsumerWidget { - const NotificationPrefsPage({super.key}); - @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - appBar: AppBar(title: const Text('通知偏好')), - body: ListView(children: [ - _SwitchTile(icon: Icons.medication, title: '用药提醒'), - _SwitchTile(icon: Icons.calendar_month, title: '复查提醒'), - _SwitchTile(icon: Icons.chat, title: '医生回复'), - _SwitchTile(icon: Icons.warning_amber, title: '异常警告'), - ]), - ); -} - -class _SwitchTile extends StatefulWidget { - final IconData icon; final String title; - const _SwitchTile({required this.icon, required this.title}); - @override State<_SwitchTile> createState() => _SwitchTileState(); -} -class _SwitchTileState extends State<_SwitchTile> { - bool _on = true; - @override Widget build(BuildContext context) => SwitchListTile(secondary: Icon(widget.icon), title: Text(widget.title), value: _on, onChanged: (v) => setState(() => _on = v)); + @override Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Container( + margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 2), + 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))), + 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])), + if (trailingText == null || trailingText!.isEmpty) const SizedBox(), + Icon(Icons.chevron_right, size: 20, color: Colors.grey[300]), + ]), + ), + ); + } } diff --git a/health_app/lib/widgets/health_drawer.dart b/health_app/lib/widgets/health_drawer.dart index b1c1d86..eb5ec08 100644 --- a/health_app/lib/widgets/health_drawer.dart +++ b/health_app/lib/widgets/health_drawer.dart @@ -18,8 +18,8 @@ class HealthDrawer extends ConsumerWidget { return Drawer( child: SafeArea( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, + child: ListView( + padding: EdgeInsets.zero, children: [ // 用户信息 Container( @@ -75,13 +75,15 @@ class HealthDrawer extends ConsumerWidget { TextButton(onPressed: () => ref.invalidate(conversationListProvider), child: const Text('刷新', style: TextStyle(fontSize: 12, color: Color(0xFF635BFF)))), ]), ), - Expanded( + SizedBox( + height: 200, child: conversations.when( data: (items) { if (items.isEmpty) { return const Center(child: Text('暂无历史对话', style: TextStyle(color: Color(0xFF999999), fontSize: 14))); } return ListView.builder( + shrinkWrap: true, padding: const EdgeInsets.symmetric(horizontal: 8), itemCount: items.length, itemBuilder: (ctx, i) => _ConversationItem(item: items[i], ref: ref), @@ -162,8 +164,8 @@ class _ConversationItem extends ConsumerWidget { ), child: Icon(_getAgentIcon(item.agent), size: 18, color: const Color(0xFF635BFF)), ), - title: Text(item.title, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), - subtitle: Text(item.lastMessage, style: TextStyle(fontSize: 12, color: Colors.grey[500])), + title: Text(item.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.w500)), + subtitle: Text(item.lastMessage, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 12, color: Colors.grey[500])), trailing: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ diff --git a/微信图片_20260603102503_4528_320.jpg b/微信图片_20260603102503_4528_320.jpg new file mode 100644 index 0000000..2d81464 Binary files /dev/null and b/微信图片_20260603102503_4528_320.jpg differ