fix: VLM 参数优化 - temperature 0.7, top_p 0.8, 指令放 system+user
- VisionAsync 新增 Temperature=0.7, TopP=0.8 - system prompt 用专业营养识别指令 - userText 用简短"请看图识别食物"配合图片 - 修复重复 prompt 导致 VLM 误读文本的 bug
@@ -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);
|
||||
|
||||
@@ -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<ToolDefinition>? Tools { get; set; }
|
||||
public string? ToolChoice { get; set; }
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
Before Width: | Height: | Size: 5.2 MiB After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 914 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 257 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
|
After Width: | Height: | Size: 602 KiB |
@@ -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':
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.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<LoginPage> createState() => _LoginPageState();
|
||||
@override ConsumerState<LoginPage> createState() => _LoginPageState();
|
||||
}
|
||||
|
||||
class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
@@ -20,165 +18,70 @@ class _LoginPageState extends ConsumerState<LoginPage> {
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_phoneCtrl.dispose();
|
||||
_codeCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
@override void dispose() { _phoneCtrl.dispose(); _codeCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _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<void> _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(),
|
||||
);
|
||||
final err = await ref.read(authProvider.notifier).login(_phoneCtrl.text.trim(), _codeCtrl.text.trim());
|
||||
setState(() => _loading = false );
|
||||
if (err != null) {
|
||||
setState(() => _error = err);
|
||||
return;
|
||||
}
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
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),
|
||||
|
||||
// 登录按钮
|
||||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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<TrendPage> {
|
||||
|
||||
@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: [
|
||||
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: 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<List<Map<String, dynamic>>>(
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
)),
|
||||
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<TrendPage> {
|
||||
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 = <Offset>[];
|
||||
final points2 = <Offset>[];
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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, DietState>(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(
|
||||
|
||||
@@ -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<HomePage> createState() => _HomePageState();
|
||||
@override ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
@@ -24,25 +20,12 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
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<HomePage> {
|
||||
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<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
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(dynamic user) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
|
||||
child: Row(children: [
|
||||
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 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 _buildHeader(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
child: Row(children: [
|
||||
Builder(builder: (ctx) => IconButton(
|
||||
icon: const Icon(Icons.menu, size: 24),
|
||||
onPressed: () => Scaffold.of(ctx).openDrawer(),
|
||||
)),
|
||||
const Spacer(),
|
||||
Text('健康管家', style: Theme.of(context).textTheme.titleLarge),
|
||||
const Spacer(),
|
||||
const SizedBox(width: 48),
|
||||
]),
|
||||
);
|
||||
String _getGreeting() {
|
||||
final hour = DateTime.now().hour;
|
||||
if (hour < 9) return '早上好';
|
||||
if (hour < 12) return '上午好';
|
||||
if (hour < 18) return '下午好';
|
||||
return '晚上好';
|
||||
}
|
||||
|
||||
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 _buildTaskCards() {
|
||||
final latestHealth = ref.watch(latestHealthProvider);
|
||||
|
||||
return latestHealth.when(
|
||||
data: (data) {
|
||||
final tasks = _getTaskCards(data);
|
||||
if (tasks.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
data: (data) => 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)),
|
||||
),
|
||||
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),
|
||||
]),
|
||||
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(
|
||||
error: (_, __) => 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))],
|
||||
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({}),
|
||||
],
|
||||
),
|
||||
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 '夜深了';
|
||||
List<Widget> _getTodayTasks(Map<String, dynamic> healthData) {
|
||||
return [
|
||||
_taskRow(icon: Icons.medication_rounded, label: '计划 8:00 吃 阿司匹林 100mg', status: 'done', onTap: _handleMedicationCheck),
|
||||
_taskRow(icon: Icons.directions_run, label: '今日待运动:散步 30 分钟', status: 'pending', onTap: null),
|
||||
_taskRow(icon: Icons.today, label: '今日测量:血压', status: 'pending', onTap: () => _textCtrl.text = '血压 '),
|
||||
..._buildAbnormalRows(healthData),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> _getTaskCards(Map<String, dynamic> healthData) {
|
||||
final cards = <Widget>[];
|
||||
|
||||
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 _buildMedicationCard() {
|
||||
return _buildTaskCard(
|
||||
'💊',
|
||||
'计划 8:00 吃 阿司匹林 100mg',
|
||||
Icons.check_circle_outline,
|
||||
() => _handleMedicationCheck(),
|
||||
type: 'medication',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildExerciseCard() {
|
||||
return _buildTaskCard(
|
||||
'🏃',
|
||||
'今日待运动:散步 30 分钟',
|
||||
Icons.check_circle_outline,
|
||||
() => _handleExerciseCheck(),
|
||||
type: 'exercise',
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMeasurementCard() {
|
||||
return _buildTaskCard(
|
||||
'🩺',
|
||||
'今日待测量:血压',
|
||||
Icons.arrow_forward_ios,
|
||||
() => _textCtrl.text = '血压 ',
|
||||
type: 'measurement',
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _buildAbnormalCards(Map<String, dynamic> healthData) {
|
||||
final cards = <Widget>[];
|
||||
|
||||
List<Widget> _buildAbnormalRows(Map<String, dynamic> healthData) {
|
||||
final rows = <Widget>[];
|
||||
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,
|
||||
));
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
Widget? _buildSummaryCard(Map<String, dynamic> healthData) {
|
||||
final values = <String>[];
|
||||
|
||||
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,
|
||||
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(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: Icon(actionIcon, size: 20, color: highlight ? const Color(0xFFDC2626) : const Color(0xFF635BFF)),
|
||||
),
|
||||
child: Row(children: [
|
||||
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<HomePage> {
|
||||
|
||||
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<Widget> _getAgentButtons(ActiveAgent agent) {
|
||||
final buttons = <Widget>[];
|
||||
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(
|
||||
Widget _agentBtn(String label, IconData icon) {
|
||||
return 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),
|
||||
),
|
||||
),
|
||||
),
|
||||
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<void> _pickImage(ImageSource source) async {
|
||||
final picker = ImagePicker();
|
||||
final picked = await picker.pickImage(source: source, imageQuality: 85);
|
||||
if (picked != null) {
|
||||
final token = await ref.read(apiClientProvider).accessToken;
|
||||
if (token == null) return;
|
||||
_textCtrl.text = '[图片已上传] $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: [
|
||||
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: const InputDecoration(hintText: '输入你想说的...', contentPadding: EdgeInsets.symmetric(horizontal: 12), border: InputBorder.none),
|
||||
onSubmitted: (_) => _sendMessage(),
|
||||
),
|
||||
),
|
||||
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),
|
||||
]),
|
||||
);
|
||||
]));
|
||||
}
|
||||
}
|
||||
|
||||
97
health_app/lib/pages/medication/medication_edit_page.dart
Normal file
@@ -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<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
}
|
||||
|
||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController(text: '阿司匹林肠溶片');
|
||||
final _dosageCtrl = TextEditingController(text: '100mg');
|
||||
String _frequency = '每日1次';
|
||||
String _time = '08:00';
|
||||
DateTime _startDate = DateTime.now();
|
||||
String _duration = '长期服用';
|
||||
|
||||
@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<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||
);
|
||||
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<String>(
|
||||
context: context,
|
||||
builder: (ctx) => SafeArea(child: Column(mainAxisSize: MainAxisSize.min, children: options.map((o) => ListTile(title: Text(o), onTap: () => Navigator.pop(ctx, o))).toList())),
|
||||
);
|
||||
if (selected != null && mounted) setState(() => _duration = selected);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
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)),
|
||||
]),
|
||||
),
|
||||
],
|
||||
),
|
||||
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(
|
||||
itemCount: list.length,
|
||||
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];
|
||||
final times = (m['timeOfDay'] as List?)?.cast<String>() ?? [];
|
||||
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);
|
||||
}),
|
||||
),
|
||||
);
|
||||
return _MedicationCard(data: m);
|
||||
},
|
||||
);
|
||||
},
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (_, _) => _empty(context),
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: () { pushRoute(ref, 'medicationAdd'); ref.invalidate(medicationListProvider); },
|
||||
icon: const Icon(Icons.add), label: const Text('添加药品'),
|
||||
),
|
||||
);
|
||||
}
|
||||
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<MedicationEditPage> createState() => _MedicationEditPageState();
|
||||
}
|
||||
class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
final _nameCtrl = TextEditingController(); final _dosageCtrl = TextEditingController(); final _timeCtrl = TextEditingController();
|
||||
@override void dispose() { _nameCtrl.dispose(); _dosageCtrl.dispose(); _timeCtrl.dispose(); super.dispose(); }
|
||||
|
||||
Future<void> _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) => 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('保存'))),
|
||||
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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MedicationCard extends StatelessWidget {
|
||||
final Map<String, dynamic> data;
|
||||
const _MedicationCard({required this.data});
|
||||
|
||||
@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)),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
68
health_app/lib/pages/profile/profile_detail_page.dart
Normal file
@@ -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<Map<String, dynamic>> healthData) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(color: Colors.white, borderRadius: BorderRadius.circular(20), boxShadow: [BoxShadow(color: const Color(0xFF635BFF).withAlpha(10), blurRadius: 8, offset: const Offset(0, 2))]),
|
||||
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<String, dynamic> 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<String, dynamic> 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))))]));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
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(height: 12),
|
||||
Text(user?.name ?? '未设置', style: Theme.of(context).textTheme.titleLarge),
|
||||
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(user?.phone ?? '', style: Theme.of(context).textTheme.bodyMedium),
|
||||
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<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定退出?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||||
],
|
||||
),
|
||||
),
|
||||
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<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text('退出登录'), content: const Text('确定退出?'),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
|
||||
);
|
||||
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
||||
},
|
||||
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 _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]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
41
health_app/lib/pages/report/ai_analysis_page.dart
Normal file
@@ -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<String, dynamic> 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()]);
|
||||
}
|
||||
216
health_app/lib/pages/settings/notification_prefs_page.dart
Normal file
@@ -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, Map<String, bool>>(
|
||||
NotificationPrefsNotifier.new,
|
||||
);
|
||||
|
||||
class NotificationPrefsNotifier extends Notifier<Map<String, bool>> {
|
||||
@override
|
||||
Map<String, bool> 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<bool> 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)))]),
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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<bool>(context: context, builder: (ctx) => AlertDialog(
|
||||
title: const Text('退出登录'), content: const Text('确定退出?'),
|
||||
actions: [TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')), TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定'))]));
|
||||
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
||||
}),
|
||||
]),
|
||||
|
||||
@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<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定退出?'),
|
||||
actions: [
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, false), child: const Text('取消')),
|
||||
TextButton(onPressed: () => Navigator.pop(ctx, true), child: const Text('确定')),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) { await ref.read(authProvider.notifier).logout(); goRoute(ref, 'login'); }
|
||||
},
|
||||
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: '异常警告'),
|
||||
@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]),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
BIN
微信图片_20260603102503_4528_320.jpg
Normal file
|
After Width: | Height: | Size: 602 KiB |