feat: AI 对话附件上下文解析 + 历史会话归档 + 多页面 UI 重构
- 后端: 新增 AttachmentContextBuilder 解析图片/PDF 摘要并拼入 LLM 上下文; ai_chat_endpoints 扩展附件接口; 新增 ReportAnalysisService - 前端: 新增历史会话页与 conversation_history_provider; chat 链路支持附件展示与回放 - UI: 重构 medication_checkin / notification_center / profile / health_drawer 等多页面 - 配置: api_client baseUrl 适配当前 WiFi IP
This commit is contained in:
@@ -143,7 +143,7 @@ class _DoctorChatPageState extends ConsumerState<DoctorChatPage> {
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? AppTheme.primary : const Color(0xFFFEFEFF),
|
||||
|
||||
@@ -359,6 +359,7 @@ class _DeviceManagementPageState extends ConsumerState<DeviceManagementPage>
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
|
||||
title: const Text('解绑设备'),
|
||||
content: Text('确定解绑这台${device.type.label}吗?'),
|
||||
actions: [
|
||||
@@ -388,29 +389,33 @@ class _DevicesHeader extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 14),
|
||||
padding: const EdgeInsets.fromLTRB(18, 18, 18, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.border),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFF60A5FA), Color(0xFF8B5CF6)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_audio_rounded,
|
||||
color: Color(0xFF2563EB),
|
||||
size: 23,
|
||||
color: Colors.white,
|
||||
size: 27,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -418,17 +423,17 @@ class _DevicesHeader extends StatelessWidget {
|
||||
const Text(
|
||||
'已绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'$deviceCount 台设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
@@ -466,24 +471,24 @@ class _DeviceSection extends StatelessWidget {
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30,
|
||||
height: 30,
|
||||
width: 34,
|
||||
height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(11),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(
|
||||
type.icon,
|
||||
size: 17,
|
||||
size: 19,
|
||||
color: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 9),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
type.label,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
@@ -530,10 +535,10 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
final accent = connected ? AppColors.success : AppColors.textHint;
|
||||
return AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
padding: const EdgeInsets.fromLTRB(14, 12, 8, 12),
|
||||
padding: const EdgeInsets.fromLTRB(16, 15, 10, 15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: connected ? AppColors.success : AppColors.border,
|
||||
width: connected ? 1.4 : 1.1,
|
||||
@@ -552,17 +557,18 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 50,
|
||||
height: 50,
|
||||
decoration: BoxDecoration(
|
||||
color: connected
|
||||
? AppColors.successLight
|
||||
: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(device.type.icon, color: accent, size: 22),
|
||||
child: Icon(device.type.icon, color: accent, size: 25),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -572,16 +578,16 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'最近同步:${_formatLastSync(device.lastSyncAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
@@ -592,7 +598,7 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
if (connected)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(right: 4),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.successLight,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
@@ -600,7 +606,8 @@ class _BoundDeviceTile extends StatelessWidget {
|
||||
child: const Text(
|
||||
'已连接',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
fontSize: 12,
|
||||
height: 1,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.success,
|
||||
),
|
||||
@@ -638,32 +645,33 @@ class _EmptyDevices extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.fromLTRB(24, 42, 24, 42),
|
||||
padding: const EdgeInsets.fromLTRB(24, 46, 24, 46),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: AppColors.borderLight, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 62,
|
||||
height: 62,
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.bluetooth_disabled_rounded,
|
||||
color: Color(0xFF2563EB),
|
||||
size: 31,
|
||||
size: 36,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'还没有绑定设备',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
@@ -673,7 +681,7 @@ class _EmptyDevices extends StatelessWidget {
|
||||
'右上角添加设备',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
|
||||
@@ -393,16 +393,32 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
final type = HealthBleService.deviceTypeFromScan(result);
|
||||
final name = _deviceNameOf(result, type);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(type?.icon ?? Icons.bluetooth_rounded, color: AppColors.primary),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: AppColors.primary.withValues(alpha: 0.10),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
type?.icon ?? Icons.bluetooth_rounded,
|
||||
color: AppColors.primary,
|
||||
size: 25,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
@@ -410,27 +426,41 @@ class _DeviceResultTile extends StatelessWidget {
|
||||
Text(
|
||||
type?.label ?? '健康设备',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: connecting ? null : onConnect,
|
||||
child: Text(connecting ? '连接中' : '连接'),
|
||||
const SizedBox(width: 10),
|
||||
SizedBox(
|
||||
height: 38,
|
||||
child: FilledButton(
|
||||
onPressed: connecting ? null : onConnect,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
child: Text(connecting ? '连接中' : '连接'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -358,7 +358,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
|
||||
Widget _buildResultView(BuildContext context, WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
final screenW = MediaQuery.of(context).size.width;
|
||||
final screenW = MediaQuery.sizeOf(context).width;
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
@@ -388,16 +388,15 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
const SizedBox(height: 16),
|
||||
if (state.isAnalyzing)
|
||||
_buildAnalyzing(state)
|
||||
else if (state.foods.isEmpty)
|
||||
_buildNoFoodHint()
|
||||
else ...[
|
||||
if (state.foods.isNotEmpty) ...[
|
||||
_buildFoodList(ref),
|
||||
_buildFoodList(ref),
|
||||
const SizedBox(height: 16),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null && state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildNutritionCard(ref),
|
||||
if (state.commentary != null &&
|
||||
state.commentary!.isNotEmpty) ...[
|
||||
const SizedBox(height: 16),
|
||||
_buildAiCommentary(state.commentary!),
|
||||
],
|
||||
_buildAiCommentary(state.commentary!),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
_buildSaveButton(),
|
||||
@@ -498,6 +497,43 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildNoFoodHint() {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 36, horizontal: 18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
size: 44,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
const Text(
|
||||
'未识别到食物',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text(
|
||||
'请重新拍摄或选择含有食物的清晰照片',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ─────────── 食物列表 ───────────
|
||||
Widget _buildFoodList(WidgetRef ref) {
|
||||
final state = ref.watch(dietProvider);
|
||||
|
||||
@@ -186,9 +186,7 @@ class _DoctorFollowUpEditPageState
|
||||
);
|
||||
if (d != null) setState(() => _date = d);
|
||||
},
|
||||
child: _pickerBox(
|
||||
'${_date.year} ${_date.month.toString().padLeft(2, '0')} ${_date.day.toString().padLeft(2, '0')}',
|
||||
),
|
||||
child: _pickerBox(_displayDate(_date)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
@@ -298,3 +296,7 @@ final _fupDetailForEdit = FutureProvider.family<Map<String, dynamic>?, String>((
|
||||
orElse: () => null,
|
||||
);
|
||||
});
|
||||
|
||||
String _displayDate(DateTime date) {
|
||||
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
352
health_app/lib/pages/history/conversation_history_page.dart
Normal file
@@ -0,0 +1,352 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/chat_provider.dart';
|
||||
import '../../providers/conversation_history_provider.dart';
|
||||
|
||||
/// 对话历史完整列表页。
|
||||
/// - 左滑删除(真删,删后 ScaffoldMessenger 提示)
|
||||
/// - 顶部"清空全部"按钮(确认弹窗 → 调 deleteAll)
|
||||
/// - 点击一条 → loadConversation → popRoute 回首页
|
||||
class ConversationHistoryPage extends ConsumerWidget {
|
||||
const ConversationHistoryPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final async = ref.watch(conversationHistoryProvider);
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.9),
|
||||
title: const Text('最近 7 次对话'),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => _confirmClearAll(context, ref),
|
||||
child: const Text(
|
||||
'清空',
|
||||
style: TextStyle(
|
||||
color: AppColors.error,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: () =>
|
||||
ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||
child: async.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (e, _) => _ErrorBlock(
|
||||
message: '历史记录加载失败',
|
||||
onRetry: () =>
|
||||
ref.read(conversationHistoryProvider.notifier).refresh(),
|
||||
),
|
||||
data: (list) => list.isEmpty
|
||||
? const _EmptyHint()
|
||||
: ListView.separated(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 24),
|
||||
itemCount: list.length,
|
||||
separatorBuilder: (_, index) => const SizedBox(height: 10),
|
||||
itemBuilder: (context, index) {
|
||||
final item = list[index];
|
||||
return _HistoryTile(
|
||||
item: item,
|
||||
onTap: () => _openConversation(ref, item.id),
|
||||
onDelete: () => _deleteOne(context, ref, item.id),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openConversation(WidgetRef ref, String id) async {
|
||||
await ref.read(chatProvider.notifier).loadConversation(id);
|
||||
popRoute(ref);
|
||||
}
|
||||
|
||||
Future<void> _deleteOne(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String id,
|
||||
) async {
|
||||
try {
|
||||
await ref.read(conversationHistoryProvider.notifier).deleteOne(id);
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('已删除'), duration: Duration(seconds: 1)),
|
||||
);
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('删除失败,请稍后重试'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _confirmClearAll(BuildContext context, WidgetRef ref) 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),
|
||||
style: TextButton.styleFrom(foregroundColor: AppColors.error),
|
||||
child: const Text('清空'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok != true) return;
|
||||
try {
|
||||
final count = await ref
|
||||
.read(conversationHistoryProvider.notifier)
|
||||
.clearAll();
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text('已清空 $count 条对话')));
|
||||
}
|
||||
} catch (_) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('清空失败,请稍后重试'),
|
||||
backgroundColor: AppColors.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _HistoryTile extends StatelessWidget {
|
||||
final ConversationListItem item;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
const _HistoryTile({
|
||||
required this.item,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dismissible(
|
||||
key: ValueKey(item.id),
|
||||
direction: DismissDirection.endToStart,
|
||||
background: Container(
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 22),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.delete_outline, color: Colors.white),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'删除',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
confirmDismiss: (_) async {
|
||||
onDelete();
|
||||
return true;
|
||||
},
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.forum_rounded,
|
||||
color: Color(0xFF2563EB),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_displaySummary(item),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
if (_displayOriginalQuestion(item).isNotEmpty) ...[
|
||||
Text(
|
||||
'首问:${_displayOriginalQuestion(item)}',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
],
|
||||
Text(
|
||||
'${item.messageCount} 条 · ${_relativeTime(item.updatedAt)}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static String _displayTitle(ConversationListItem item) {
|
||||
final s = item.summary?.trim();
|
||||
if (s != null && s.isNotEmpty) return s;
|
||||
final t = item.title?.trim();
|
||||
if (t != null && t.isNotEmpty) return t;
|
||||
return '未命名对话';
|
||||
}
|
||||
|
||||
static String _displaySummary(ConversationListItem item) {
|
||||
final s = item.summary?.trim();
|
||||
if (s != null && s.isNotEmpty) return s.replaceAll(RegExp(r'\s+'), ' ');
|
||||
return _displayTitle(item);
|
||||
}
|
||||
|
||||
static String _displayOriginalQuestion(ConversationListItem item) {
|
||||
final t = item.title?.trim();
|
||||
if (t == null || t.isEmpty) return '';
|
||||
final summary = item.summary?.trim();
|
||||
if (summary != null && summary.isNotEmpty && summary != t) {
|
||||
return t.replaceAll(RegExp(r'\s+'), ' ');
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
static String _relativeTime(DateTime time) {
|
||||
final now = DateTime.now();
|
||||
final local = time.toLocal();
|
||||
final diff = now.difference(local);
|
||||
if (diff.inMinutes < 1) return '刚刚';
|
||||
if (diff.inHours < 1) return '${diff.inMinutes} 分钟前';
|
||||
if (diff.inDays < 1) return '${diff.inHours} 小时前';
|
||||
if (diff.inDays < 7) return '${diff.inDays} 天前';
|
||||
return '${local.year}/${local.month}/${local.day}';
|
||||
}
|
||||
}
|
||||
|
||||
class _EmptyHint extends StatelessWidget {
|
||||
const _EmptyHint();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView(
|
||||
children: const [
|
||||
SizedBox(height: 120),
|
||||
Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.forum_outlined, size: 56, color: AppColors.textHint),
|
||||
SizedBox(height: 14),
|
||||
Text(
|
||||
'暂无历史对话',
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6),
|
||||
Text(
|
||||
'在首页和 AI 健康助手聊聊吧',
|
||||
style: TextStyle(fontSize: 13, color: AppColors.textSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ErrorBlock extends StatelessWidget {
|
||||
final String message;
|
||||
final VoidCallback onRetry;
|
||||
const _ErrorBlock({required this.message, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.wifi_off_rounded,
|
||||
size: 56,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(message, style: const TextStyle(color: AppColors.textSecondary)),
|
||||
const SizedBox(height: 14),
|
||||
OutlinedButton(onPressed: onRetry, child: const Text('重试')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -21,7 +21,8 @@ class HomePage extends ConsumerStatefulWidget {
|
||||
ConsumerState<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends ConsumerState<HomePage> {
|
||||
class _HomePageState extends ConsumerState<HomePage>
|
||||
with WidgetsBindingObserver {
|
||||
final _textCtrl = TextEditingController();
|
||||
final _scrollCtrl = ScrollController();
|
||||
final _focusNode = FocusNode();
|
||||
@@ -32,6 +33,7 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
WidgetsBinding.instance.addPostFrameCallback(
|
||||
(_) => ref.invalidate(notificationUnreadCountProvider),
|
||||
);
|
||||
@@ -41,8 +43,19 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeMetrics() {
|
||||
// 键盘动画期间每帧都会回调,让列表底部始终贴住输入区上沿
|
||||
if (!_focusNode.hasFocus) return;
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted || !_scrollCtrl.hasClients) return;
|
||||
_scrollCtrl.jumpTo(_scrollCtrl.position.maxScrollExtent);
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
_notificationTimer?.cancel();
|
||||
_textCtrl.dispose();
|
||||
_scrollCtrl.dispose();
|
||||
@@ -102,16 +115,18 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFFFFCFF),
|
||||
drawer: const HealthDrawer(),
|
||||
drawerEdgeDragWidth: MediaQuery.of(context).size.width * 0.35,
|
||||
drawerEdgeDragWidth: MediaQuery.sizeOf(context).width * 0.65,
|
||||
body: AppBackground(
|
||||
safeArea: true,
|
||||
child: Column(
|
||||
children: [
|
||||
_buildHeader(user),
|
||||
Expanded(
|
||||
child: ChatMessagesView(
|
||||
scrollCtrl: _scrollCtrl,
|
||||
messages: chatState.messages,
|
||||
child: RepaintBoundary(
|
||||
child: ChatMessagesView(
|
||||
scrollCtrl: _scrollCtrl,
|
||||
messages: chatState.messages,
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildBottomBar(context),
|
||||
@@ -472,17 +487,29 @@ class _HomePageState extends ConsumerState<HomePage> {
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.file_open_outlined,
|
||||
Icons.picture_as_pdf_outlined,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
title: const Text('传文件'),
|
||||
title: const Text('上传 PDF'),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles();
|
||||
if (result != null && result.files.isNotEmpty) {
|
||||
_textCtrl.text = '[文件已选择] ${result.files.first.name}';
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
withData: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final pdfFile = result.files.first;
|
||||
final path = pdfFile.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
// 上传 + 让 AI 看 PDF 内容
|
||||
await ref.read(chatProvider.notifier).sendPdf(
|
||||
path,
|
||||
pdfFile.name,
|
||||
_textCtrl.text.trim(),
|
||||
);
|
||||
_textCtrl.clear();
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
],
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
import '../../../core/app_colors.dart';
|
||||
import '../../../core/app_theme.dart';
|
||||
import '../../../core/api_client.dart' show baseUrl;
|
||||
import '../../../core/navigation_provider.dart';
|
||||
import '../../../providers/chat_provider.dart';
|
||||
import '../../../providers/data_providers.dart';
|
||||
@@ -88,10 +89,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'开始和小脉健康对话吧',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
Text('开始和小脉健康对话吧', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'记录健康数据,获取专业建议',
|
||||
@@ -138,7 +136,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
msg.content.trim().isEmpty) {
|
||||
return _buildThinkingBubble(context, chatState.thinkingText);
|
||||
}
|
||||
return _buildTextBubble(context, msg, chatState);
|
||||
return _buildTextBubble(context, ref, msg, chatState);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -154,7 +152,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
) {
|
||||
final info = _agentInfo(agent);
|
||||
final actions = agent.actions;
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
final agentColors = _agentColors(agent);
|
||||
final artworkPath = _agentArtworkPath(agent);
|
||||
|
||||
@@ -467,7 +465,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
) {
|
||||
final meta = msg.metadata ?? <String, dynamic>{};
|
||||
final backendType = meta['type'] as String? ?? '';
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final screenWidth = MediaQuery.sizeOf(context).width;
|
||||
|
||||
// 识别是哪种数据类型
|
||||
final isMedication =
|
||||
@@ -501,23 +499,17 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final time = meta['time'] as String? ?? '';
|
||||
if (time.isNotEmpty) {
|
||||
final timeValue = meta['服药时间'] as String? ?? time;
|
||||
fields.add(
|
||||
_ConfirmField(label: '服药时间', value: timeValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '服药时间', value: timeValue));
|
||||
}
|
||||
final frequency = meta['frequency'] as String? ?? '';
|
||||
if (frequency.isNotEmpty) {
|
||||
final freqValue = meta['频率'] as String? ?? _freqLabel(frequency);
|
||||
fields.add(
|
||||
_ConfirmField(label: '频率', value: freqValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '频率', value: freqValue));
|
||||
}
|
||||
final duration = meta['duration_days'] as int?;
|
||||
if (duration != null && duration > 0) {
|
||||
final durationValue = meta['服用天数'] as String? ?? '$duration 天';
|
||||
fields.add(
|
||||
_ConfirmField(label: '服用天数', value: durationValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '服用天数', value: durationValue));
|
||||
}
|
||||
} else if (isExercise) {
|
||||
// 运动计划 — 主展示区大号显示运动名,字段列时长/频率/天数
|
||||
@@ -558,17 +550,13 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
final intensity = (meta['intensity'] ?? meta['运动强度'] ?? '').toString();
|
||||
if (intensity.isNotEmpty) {
|
||||
fields.add(
|
||||
_ConfirmField(label: '运动强度', value: intensity),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '运动强度', value: intensity));
|
||||
}
|
||||
final calories =
|
||||
(meta['calories'] ?? meta['calorie'] ?? meta['消耗热量'] ?? '')
|
||||
.toString();
|
||||
if (calories.isNotEmpty) {
|
||||
fields.add(
|
||||
_ConfirmField(label: '消耗热量', value: '$calories kcal'),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '消耗热量', value: '$calories kcal'));
|
||||
}
|
||||
} else {
|
||||
// 健康指标 — 主展示区已显示指标+数值+单位,详情只列额外信息
|
||||
@@ -580,15 +568,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
if (recordTime.isEmpty) recordTime = _formatTime(msg.createdAt);
|
||||
final timeValue = meta['记录时间'] as String? ?? recordTime;
|
||||
fields.add(
|
||||
_ConfirmField(label: '记录时间', value: timeValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '记录时间', value: timeValue));
|
||||
final note = meta['note'] as String? ?? '';
|
||||
if (note.isNotEmpty) {
|
||||
final noteValue = meta['备注'] as String? ?? note;
|
||||
fields.add(
|
||||
_ConfirmField(label: '备注', value: noteValue),
|
||||
);
|
||||
fields.add(_ConfirmField(label: '备注', value: noteValue));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -911,9 +895,9 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
.read(chatProvider.notifier)
|
||||
.confirmMessage(msg.id);
|
||||
if (error != null && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text(error)),
|
||||
);
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(error)));
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
@@ -999,16 +983,15 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
padding: const EdgeInsets.all(1.4),
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
gradient: AppColors.actionOutlineGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(20),
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
border: Border.all(color: AppColors.border, width: 1.5),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withAlpha(12),
|
||||
@@ -1017,28 +1000,40 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
|
||||
decoration: const BoxDecoration(
|
||||
color: AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(3),
|
||||
topRight: Radius.circular(18.6),
|
||||
bottomLeft: Radius.circular(18.6),
|
||||
bottomRight: Radius.circular(18.6),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
padding: const EdgeInsets.all(5),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryLight,
|
||||
borderRadius: BorderRadius.circular(13),
|
||||
),
|
||||
child: const CircularProgressIndicator(
|
||||
strokeWidth: 2.2,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
thinkingText?.isNotEmpty == true ? thinkingText! : '正在分析...',
|
||||
style: const TextStyle(fontSize: 17, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1046,153 +1041,228 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
Widget _buildTextBubble(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
ChatMessage msg,
|
||||
ChatState? chatState,
|
||||
) {
|
||||
final isUser = msg.isUser;
|
||||
final imageUrl = msg.metadata?['imageUrl'] as String?;
|
||||
final localPath = msg.metadata?['localImagePath'] as String?;
|
||||
final pdfFileName =
|
||||
msg.metadata?['pdfFileName']?.toString() ??
|
||||
msg.metadata?['fileName']?.toString();
|
||||
final hasImage = imageUrl != null || localPath != null;
|
||||
final hasPdf = pdfFileName != null && pdfFileName.isNotEmpty;
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
constraints: BoxConstraints(
|
||||
maxWidth: MediaQuery.of(context).size.width * 0.82,
|
||||
maxWidth: MediaQuery.sizeOf(context).width * 0.82,
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? Colors.white : AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isUser ? 20 : 4),
|
||||
topRight: Radius.circular(isUser ? 4 : 20),
|
||||
bottomLeft: const Radius.circular(20),
|
||||
bottomRight: const Radius.circular(20),
|
||||
),
|
||||
border: Border.all(
|
||||
color: isUser ? const Color(0xFFE8E4FF) : AppColors.border,
|
||||
width: isUser ? 1 : 1.5,
|
||||
),
|
||||
boxShadow: isUser
|
||||
? []
|
||||
: [
|
||||
padding: EdgeInsets.all(isUser ? 0 : 1.4),
|
||||
decoration: isUser
|
||||
? null
|
||||
: BoxDecoration(
|
||||
gradient: AppColors.actionOutlineGradient,
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(4),
|
||||
topRight: Radius.circular(20),
|
||||
bottomLeft: Radius.circular(20),
|
||||
bottomRight: Radius.circular(20),
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: AppTheme.primary.withAlpha(12),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文字内容
|
||||
if (isUser)
|
||||
SelectableText(
|
||||
msg.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
)
|
||||
else
|
||||
MarkdownBody(
|
||||
data: _cleanAiText(msg.content),
|
||||
selectable: true,
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? AppTheme.primary : AppColors.cardBackground,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(isUser ? 18.6 : 3),
|
||||
topRight: Radius.circular(isUser ? 3 : 18.6),
|
||||
bottomLeft: const Radius.circular(18.6),
|
||||
bottomRight: const Radius.circular(18.6),
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// 文字内容
|
||||
if (isUser)
|
||||
SelectableText(
|
||||
msg.content,
|
||||
style: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
color: Colors.white,
|
||||
height: 1.5,
|
||||
),
|
||||
strong: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h1: const TextStyle(
|
||||
fontSize: 21,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h2: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// 图片缩略图(在文字下方)
|
||||
if (hasImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 160,
|
||||
maxHeight: 120,
|
||||
),
|
||||
child: localPath != null
|
||||
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||
: imageUrl != null
|
||||
? Image.network(
|
||||
imageUrl,
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(
|
||||
width: 80,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
size: 28,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
)
|
||||
else
|
||||
MarkdownBody(
|
||||
data: _cleanAiText(msg.content),
|
||||
selectable: true,
|
||||
onTapLink: (text, href, title) =>
|
||||
_handleMarkdownLink(context, ref, href),
|
||||
styleSheet: MarkdownStyleSheet(
|
||||
p: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
),
|
||||
a: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.primary,
|
||||
fontWeight: FontWeight.w700,
|
||||
decoration: TextDecoration.underline,
|
||||
decorationColor: AppColors.primary,
|
||||
),
|
||||
strong: const TextStyle(
|
||||
fontSize: 19,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
h1: const TextStyle(
|
||||
fontSize: 22,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.35,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
h2: const TextStyle(
|
||||
fontSize: 21,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
h3: const TextStyle(
|
||||
fontSize: 20,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.4,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
listBullet: const TextStyle(
|
||||
fontSize: 24,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.2,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
listBulletPadding: const EdgeInsets.only(right: 8),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
if (!isUser && msg.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 17,
|
||||
color: AppTheme.primary,
|
||||
// 图片缩略图(在文字下方)
|
||||
if (hasImage)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8),
|
||||
child: GestureDetector(
|
||||
onTap: () => _showFullImage(context, localPath ?? imageUrl),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxWidth: 160,
|
||||
maxHeight: 120,
|
||||
),
|
||||
child: localPath != null
|
||||
? Image.file(File(localPath), fit: BoxFit.cover)
|
||||
: imageUrl != null
|
||||
? Image.network(
|
||||
_mediaUrl(imageUrl),
|
||||
fit: BoxFit.cover,
|
||||
errorBuilder: (_, e, s) => Container(
|
||||
width: 80,
|
||||
height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.image,
|
||||
size: 28,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'小脉健康',
|
||||
style: TextStyle(fontSize: 15, color: AppColors.textHint),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
const Text(
|
||||
'仅供参考',
|
||||
style: TextStyle(fontSize: 14, color: AppColors.textHint),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
if (hasPdf)
|
||||
Container(
|
||||
margin: const EdgeInsets.only(top: 8),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser
|
||||
? Colors.white.withValues(alpha: 0.16)
|
||||
: AppColors.backgroundSecondary,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
size: 18,
|
||||
color: isUser ? Colors.white : AppColors.error,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Flexible(
|
||||
child: Text(
|
||||
pdfFileName,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: isUser
|
||||
? Colors.white
|
||||
: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
if (!isUser && msg.content.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Row(
|
||||
children: [
|
||||
const CircleAvatar(
|
||||
radius: 10,
|
||||
backgroundColor: AppTheme.primaryLight,
|
||||
child: Icon(
|
||||
Icons.chat_bubble_outline,
|
||||
size: 17,
|
||||
color: AppTheme.primary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Text(
|
||||
'内容由 AI 生成',
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -1204,6 +1274,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
|
||||
static void _showFullImage(BuildContext context, String? path) {
|
||||
if (path == null) return;
|
||||
final resolvedPath = _mediaUrl(path);
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (ctx) => Dialog(
|
||||
@@ -1216,8 +1287,10 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: InteractiveViewer(
|
||||
child: path.startsWith('http')
|
||||
? Image.network(path, fit: BoxFit.contain)
|
||||
: Image.file(File(path), fit: BoxFit.contain),
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: resolvedPath.startsWith('http')
|
||||
? Image.network(resolvedPath, fit: BoxFit.contain)
|
||||
: Image.file(File(resolvedPath), fit: BoxFit.contain),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
@@ -1241,6 +1314,12 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
);
|
||||
}
|
||||
|
||||
static String _mediaUrl(String path) {
|
||||
if (path.startsWith('http://') || path.startsWith('https://')) return path;
|
||||
if (path.startsWith('/uploads/')) return '$baseUrl$path';
|
||||
return path;
|
||||
}
|
||||
|
||||
/// 清理 AI 返回文本中的异常占位符(Markdown 格式交给 MarkdownBody 渲染)
|
||||
static String _cleanAiText(String text) {
|
||||
var t = text;
|
||||
@@ -1255,6 +1334,37 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
return t.trim();
|
||||
}
|
||||
|
||||
/// 处理 AI 回复里的 markdown 链接点击:
|
||||
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
|
||||
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
|
||||
/// - app://device → 跳到蓝牙设备页
|
||||
/// - 其他 app://xxx → 当作 route name 直接跳
|
||||
static void _handleMarkdownLink(
|
||||
BuildContext context,
|
||||
WidgetRef ref,
|
||||
String? href,
|
||||
) {
|
||||
if (href == null || href.isEmpty) return;
|
||||
final uri = Uri.tryParse(href);
|
||||
if (uri == null) return;
|
||||
|
||||
if (uri.scheme == 'app') {
|
||||
switch (uri.host) {
|
||||
case 'diet':
|
||||
ref.read(chatProvider.notifier).triggerAgent(ActiveAgent.diet, '拍饮食');
|
||||
break;
|
||||
case 'report':
|
||||
pushRoute(ref, 'reports');
|
||||
break;
|
||||
case 'device':
|
||||
pushRoute(ref, 'devices');
|
||||
break;
|
||||
default:
|
||||
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
String _freqLabel(String freq) {
|
||||
switch (freq.toLowerCase()) {
|
||||
case 'daily':
|
||||
@@ -1493,8 +1603,11 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
if (hr is Map && hr['value'] is num) {
|
||||
final v = (hr['value'] as num).toDouble();
|
||||
if (v > 100) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||||
else if (v < 60) abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||||
if (v > 100) {
|
||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏快');
|
||||
} else if (v < 60) {
|
||||
abnormals.add('心率 ${v.toStringAsFixed(0)} 偏慢');
|
||||
}
|
||||
}
|
||||
if (bs is Map && bs['value'] is num) {
|
||||
final v = (bs['value'] as num).toDouble();
|
||||
@@ -1554,7 +1667,8 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
if (plan != null) {
|
||||
final items =
|
||||
(plan['items'] as List?)?.cast<Map<String, dynamic>>() ?? [];
|
||||
final today = '${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final today =
|
||||
'${now.year}-${now.month.toString().padLeft(2, '0')}-${now.day.toString().padLeft(2, '0')}';
|
||||
final todayItem = items.cast<Map<String, dynamic>?>().firstWhere(
|
||||
(i) => i?['scheduledDate']?.toString() == today,
|
||||
orElse: () => null,
|
||||
@@ -1631,7 +1745,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
}
|
||||
|
||||
// ── 3. 用药打卡 ──
|
||||
// 只显示已过期(漏服)的药;用颜色区分严重程度;没漏服则整行不显示
|
||||
// 漏服时突出提醒;未到服药时间时也保留一行轻提示,避免今日健康缺少用药状态。
|
||||
const medIconColor = Color(0xFFEC4899);
|
||||
const medIconBg = Color(0xFFFCE7F3);
|
||||
reminders.whenOrNull(
|
||||
@@ -1639,7 +1753,21 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
final overdueMeds = meds
|
||||
.where((m) => m['status'] == 'overdue')
|
||||
.toList();
|
||||
if (overdueMeds.isEmpty) return;
|
||||
if (overdueMeds.isEmpty) {
|
||||
tasks.add(
|
||||
_taskRow(
|
||||
context,
|
||||
Icons.medication_rounded,
|
||||
'用药',
|
||||
trailing: meds.isEmpty ? '暂无用药安排' : '暂时不用吃药',
|
||||
status: 'done',
|
||||
iconColor: medIconColor,
|
||||
iconBg: medIconBg,
|
||||
onTap: () => pushRoute(ref, 'medCheckIn'),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// 按过期时长排序——先把最严重的放前面
|
||||
final now = DateTime.now();
|
||||
@@ -1658,8 +1786,7 @@ class ChatMessagesView extends ConsumerWidget {
|
||||
? 0
|
||||
: now.difference(scheduled).inMinutes / 60.0;
|
||||
return (m, overdueHours);
|
||||
}).toList()
|
||||
..sort((a, b) => b.$2.compareTo(a.$2));
|
||||
}).toList()..sort((a, b) => b.$2.compareTo(a.$2));
|
||||
|
||||
// 取最严重那一项作为代表,其他用"等"省略
|
||||
final first = withDelta.first;
|
||||
@@ -1969,10 +2096,7 @@ class _AgentAction {
|
||||
class _ConfirmField {
|
||||
final String label;
|
||||
final String value;
|
||||
const _ConfirmField({
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
const _ConfirmField({required this.label, required this.value});
|
||||
}
|
||||
|
||||
final _agentActions = <ActiveAgent, List<_AgentAction>>{
|
||||
|
||||
@@ -1,150 +1,680 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../widgets/app_empty_state.dart';
|
||||
import '../../widgets/app_error_state.dart';
|
||||
|
||||
class MedicationCheckInPage extends ConsumerStatefulWidget {
|
||||
final String? medId; // 可选:指定药品,null 显示全部
|
||||
final String? medId;
|
||||
|
||||
const MedicationCheckInPage({super.key, this.medId});
|
||||
@override ConsumerState<MedicationCheckInPage> createState() => _MedicationCheckInPageState();
|
||||
|
||||
@override
|
||||
ConsumerState<MedicationCheckInPage> createState() =>
|
||||
_MedicationCheckInPageState();
|
||||
}
|
||||
|
||||
class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
static const _medBlue = Color(0xFF60A5FA);
|
||||
static const _medViolet = Color(0xFF8B5CF6);
|
||||
static const _softGreen = Color(0xFFEAF8EF);
|
||||
|
||||
@override void initState() { super.initState(); _load(); }
|
||||
void _load() => setState(() { _future = ref.read(medicationReminderProvider.future); });
|
||||
Future<List<Map<String, dynamic>>>? _future;
|
||||
final Set<String> _busyDoses = {};
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
void _load() {
|
||||
setState(() {
|
||||
_future = ref.read(medicationReminderProvider.future);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
_load();
|
||||
await _future;
|
||||
}
|
||||
|
||||
Future<void> _toggleDose(String medId, String time, bool taken) async {
|
||||
final doseKey = '$medId-$time';
|
||||
if (_busyDoses.contains(doseKey)) return;
|
||||
|
||||
setState(() => _busyDoses.add(doseKey));
|
||||
try {
|
||||
final api = ref.read(apiClientProvider);
|
||||
if (taken) {
|
||||
// 取消打卡:删掉对应 log
|
||||
await api.delete('/api/medications/$medId/confirm-dose/$time');
|
||||
} else {
|
||||
// 打卡
|
||||
await api.post('/api/medications/$medId/confirm-dose', data: {'scheduledTime': time, 'status': 'taken'});
|
||||
await api.post(
|
||||
'/api/medications/$medId/confirm-dose',
|
||||
data: {'scheduledTime': time, 'status': 'taken'},
|
||||
);
|
||||
}
|
||||
ref.invalidate(medicationReminderProvider);
|
||||
ref.invalidate(medicationListProvider);
|
||||
_load();
|
||||
} catch (e) { debugPrint('[MedCheckIn] 打卡失败: $e'); }
|
||||
} catch (e) {
|
||||
debugPrint('[MedCheckIn] 打卡失败: $e');
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('打卡失败,请稍后重试'),
|
||||
backgroundColor: AppTheme.error,
|
||||
),
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
if (mounted) setState(() => _busyDoses.remove(doseKey));
|
||||
}
|
||||
}
|
||||
|
||||
@override Widget build(BuildContext context) {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)), title: const Text('服药打卡'), centerTitle: true),
|
||||
appBar: AppBar(
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text('服药打卡'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: FutureBuilder<List<Map<String, dynamic>>>(
|
||||
future: _future,
|
||||
builder: (ctx, snap) {
|
||||
if (snap.connectionState == ConnectionState.waiting) {
|
||||
return const Center(child: CircularProgressIndicator(color: AppTheme.primary));
|
||||
if (snap.connectionState == ConnectionState.waiting &&
|
||||
!snap.hasData) {
|
||||
return const Center(
|
||||
child: CircularProgressIndicator(color: AppTheme.primary),
|
||||
);
|
||||
}
|
||||
if (snap.hasError) {
|
||||
return AppErrorState(title: '用药信息加载失败', onRetry: _load);
|
||||
}
|
||||
final reminders = snap.data ?? [];
|
||||
|
||||
final reminders = _filterReminders(snap.data ?? []);
|
||||
if (reminders.isEmpty) {
|
||||
return Center(
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Icon(Icons.check_circle_outline, size: 64, color: AppTheme.textHint),
|
||||
const SizedBox(height: 12),
|
||||
const Text('今天所有用药已打卡', style: TextStyle(fontSize: 19, color: AppColors.textSecondary)),
|
||||
]),
|
||||
return RefreshIndicator(
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
children: const [
|
||||
SizedBox(height: 92),
|
||||
AppEmptyState(
|
||||
icon: Icons.task_alt_outlined,
|
||||
title: '今天的用药已完成',
|
||||
subtitle: '下拉可刷新最新用药提醒',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// 如果指定了 medId,只显示该药品
|
||||
var filtered = reminders;
|
||||
if (widget.medId != null) {
|
||||
filtered = reminders.where((r) => r['id']?.toString() == widget.medId).toList();
|
||||
}
|
||||
|
||||
// 按药品分组
|
||||
final grouped = <String, List<Map<String, dynamic>>>{};
|
||||
for (final r in filtered) {
|
||||
final name = r['name']?.toString() ?? '药品';
|
||||
grouped.putIfAbsent(name, () => []).add(r);
|
||||
}
|
||||
final grouped = _groupByMedication(reminders);
|
||||
final total = reminders.length;
|
||||
final taken = reminders.where(_isTaken).length;
|
||||
final pending = total - taken;
|
||||
|
||||
return RefreshIndicator(
|
||||
onRefresh: () async => _load(),
|
||||
onRefresh: _refresh,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(14),
|
||||
children: grouped.entries.map((entry) {
|
||||
final medName = entry.key;
|
||||
final doses = entry.value;
|
||||
final dosage = doses.first['dosage']?.toString() ?? '';
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.surface,
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
boxShadow: [AppTheme.shadowCard],
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 28),
|
||||
children: [
|
||||
_CheckInSummary(total: total, taken: taken, pending: pending),
|
||||
const SizedBox(height: 14),
|
||||
...grouped.entries.map(
|
||||
(entry) => _MedicationDoseCard(
|
||||
name: entry.key,
|
||||
doses: entry.value,
|
||||
busyDoses: _busyDoses,
|
||||
onToggle: _toggleDose,
|
||||
),
|
||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Row(children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(color: AppColors.warningLight, borderRadius: BorderRadius.circular(10)),
|
||||
child: const Center(child: Text('💊', style: TextStyle(fontSize: 23))),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||||
Text(medName, style: const TextStyle(fontSize: 19, fontWeight: FontWeight.w700)),
|
||||
if (dosage.isNotEmpty) Text(dosage, style: const TextStyle(fontSize: 16, color: AppColors.textSecondary)),
|
||||
]),
|
||||
]),
|
||||
const SizedBox(height: 14),
|
||||
...doses.map((d) {
|
||||
final time = d['scheduledTime']?.toString() ?? '';
|
||||
final status = d['status']?.toString() ?? 'pending';
|
||||
final isTaken = status == 'taken';
|
||||
final medId = d['id']?.toString() ?? '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8),
|
||||
child: Row(children: [
|
||||
Icon(
|
||||
isTaken ? Icons.check_circle : Icons.radio_button_unchecked,
|
||||
size: 23,
|
||||
color: isTaken ? AppTheme.success : AppTheme.border,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(time, style: TextStyle(
|
||||
fontSize: 18, fontWeight: FontWeight.w500,
|
||||
color: isTaken ? AppTheme.textSub : AppTheme.text,
|
||||
)),
|
||||
const Spacer(),
|
||||
GestureDetector(
|
||||
onTap: () => _toggleDose(medId, time, isTaken),
|
||||
child: Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: isTaken ? AppColors.successLight : AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
isTaken ? Icons.check_circle : Icons.check_circle_outline,
|
||||
size: 28,
|
||||
color: isTaken ? AppTheme.success : AppColors.textHint,
|
||||
),
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}),
|
||||
]),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _filterReminders(
|
||||
List<Map<String, dynamic>> reminders,
|
||||
) {
|
||||
if (widget.medId == null) return reminders;
|
||||
return reminders.where((r) => r['id']?.toString() == widget.medId).toList();
|
||||
}
|
||||
|
||||
Map<String, List<Map<String, dynamic>>> _groupByMedication(
|
||||
List<Map<String, dynamic>> reminders,
|
||||
) {
|
||||
final grouped = <String, List<Map<String, dynamic>>>{};
|
||||
for (final reminder in reminders) {
|
||||
final name = reminder['name']?.toString().trim();
|
||||
grouped
|
||||
.putIfAbsent(name?.isNotEmpty == true ? name! : '未命名药品', () {
|
||||
return <Map<String, dynamic>>[];
|
||||
})
|
||||
.add(reminder);
|
||||
}
|
||||
|
||||
for (final doses in grouped.values) {
|
||||
doses.sort(
|
||||
(a, b) => _formatTime(
|
||||
a['scheduledTime']?.toString() ?? '',
|
||||
).compareTo(_formatTime(b['scheduledTime']?.toString() ?? '')),
|
||||
);
|
||||
}
|
||||
return grouped;
|
||||
}
|
||||
}
|
||||
|
||||
class _CheckInSummary extends StatelessWidget {
|
||||
final int total;
|
||||
final int taken;
|
||||
final int pending;
|
||||
|
||||
const _CheckInSummary({
|
||||
required this.total,
|
||||
required this.taken,
|
||||
required this.pending,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final progress = total == 0 ? 0.0 : taken / total;
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
_MedicationCheckInPageState._medBlue,
|
||||
_MedicationCheckInPageState._medViolet,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.medication_outlined,
|
||||
color: Colors.white,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'今日服药进度',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 2),
|
||||
Text(
|
||||
'按提醒时间逐项确认,保持用药节奏',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'${(progress * 100).round()}%',
|
||||
style: const TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: _MedicationCheckInPageState._medViolet,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 9,
|
||||
value: progress,
|
||||
backgroundColor: AppColors.cardInner,
|
||||
valueColor: const AlwaysStoppedAnimation<Color>(
|
||||
_MedicationCheckInPageState._medBlue,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _SummaryStat(
|
||||
label: '待完成',
|
||||
value: '$pending',
|
||||
icon: Icons.schedule_outlined,
|
||||
color: AppColors.warning,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SummaryStat(
|
||||
label: '已打卡',
|
||||
value: '$taken',
|
||||
icon: Icons.check_circle_outline,
|
||||
color: AppTheme.success,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _SummaryStat(
|
||||
label: '总次数',
|
||||
value: '$total',
|
||||
icon: Icons.format_list_numbered_outlined,
|
||||
color: _MedicationCheckInPageState._medViolet,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SummaryStat extends StatelessWidget {
|
||||
final String label;
|
||||
final String value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _SummaryStat({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
constraints: const BoxConstraints(minHeight: 58),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 9),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: color.withValues(alpha: 0.14)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: color, size: 17),
|
||||
const SizedBox(width: 7),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MedicationDoseCard extends StatelessWidget {
|
||||
final String name;
|
||||
final List<Map<String, dynamic>> doses;
|
||||
final Set<String> busyDoses;
|
||||
final Future<void> Function(String medId, String time, bool taken) onToggle;
|
||||
|
||||
const _MedicationDoseCard({
|
||||
required this.name,
|
||||
required this.doses,
|
||||
required this.busyDoses,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dosage = doses.first['dosage']?.toString().trim() ?? '';
|
||||
final takenCount = doses.where(_isTaken).length;
|
||||
final allTaken = takenCount == doses.length;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: allTaken
|
||||
? AppTheme.success.withValues(alpha: 0.26)
|
||||
: AppColors.border,
|
||||
width: 1.1,
|
||||
),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 44,
|
||||
height: 44,
|
||||
decoration: BoxDecoration(
|
||||
color: allTaken
|
||||
? _MedicationCheckInPageState._softGreen
|
||||
: AppColors.infoLight,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
child: Icon(
|
||||
allTaken ? Icons.done_all_rounded : Icons.medication_liquid,
|
||||
color: allTaken
|
||||
? AppTheme.success
|
||||
: _MedicationCheckInPageState._medBlue,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
if (dosage.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
dosage,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: allTaken
|
||||
? _MedicationCheckInPageState._softGreen
|
||||
: AppColors.cardInner,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
'$takenCount/${doses.length}',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: allTaken
|
||||
? AppTheme.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
for (var i = 0; i < doses.length; i++) ...[
|
||||
_DoseRow(
|
||||
dose: doses[i],
|
||||
isLast: i == doses.length - 1,
|
||||
isBusy: busyDoses.contains(
|
||||
'${doses[i]['id']?.toString() ?? ''}-${doses[i]['scheduledTime']?.toString() ?? ''}',
|
||||
),
|
||||
onToggle: onToggle,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DoseRow extends StatelessWidget {
|
||||
final Map<String, dynamic> dose;
|
||||
final bool isLast;
|
||||
final bool isBusy;
|
||||
final Future<void> Function(String medId, String time, bool taken) onToggle;
|
||||
|
||||
const _DoseRow({
|
||||
required this.dose,
|
||||
required this.isLast,
|
||||
required this.isBusy,
|
||||
required this.onToggle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final time = dose['scheduledTime']?.toString() ?? '';
|
||||
final medId = dose['id']?.toString() ?? '';
|
||||
final isTaken = _isTaken(dose);
|
||||
final displayTime = _formatTime(time);
|
||||
final statusColor = isTaken ? AppTheme.success : AppColors.warning;
|
||||
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
width: 28,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
decoration: BoxDecoration(
|
||||
color: isTaken ? AppTheme.success : Colors.white,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: isTaken ? AppTheme.success : AppColors.border,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: isTaken
|
||||
? const Icon(Icons.check, size: 14, color: Colors.white)
|
||||
: null,
|
||||
),
|
||||
if (!isLast)
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: 2,
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
color: isTaken
|
||||
? AppTheme.success.withValues(alpha: 0.34)
|
||||
: AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: isLast ? 0 : 12),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.fromLTRB(12, 10, 10, 10),
|
||||
decoration: BoxDecoration(
|
||||
color: isTaken
|
||||
? _MedicationCheckInPageState._softGreen
|
||||
: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isTaken
|
||||
? AppTheme.success.withValues(alpha: 0.18)
|
||||
: AppColors.borderLight,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.access_time, size: 18, color: statusColor),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
displayTime,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: isTaken ? AppTheme.textSub : AppTheme.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 1),
|
||||
Text(
|
||||
isTaken ? '已完成打卡' : '等待确认服用',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isTaken
|
||||
? AppTheme.success
|
||||
: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
SizedBox(
|
||||
height: 36,
|
||||
child: FilledButton.icon(
|
||||
onPressed: isBusy || medId.isEmpty || time.isEmpty
|
||||
? null
|
||||
: () => onToggle(medId, time, isTaken),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: isTaken
|
||||
? Colors.white
|
||||
: _MedicationCheckInPageState._medBlue,
|
||||
foregroundColor: isTaken
|
||||
? AppTheme.success
|
||||
: Colors.white,
|
||||
disabledBackgroundColor: AppColors.cardInner,
|
||||
disabledForegroundColor: AppColors.textHint,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
minimumSize: const Size(0, 36),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
side: BorderSide(
|
||||
color: isTaken
|
||||
? AppTheme.success.withValues(alpha: 0.34)
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
),
|
||||
icon: isBusy
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
)
|
||||
: Icon(
|
||||
isTaken
|
||||
? Icons.undo_rounded
|
||||
: Icons.check_rounded,
|
||||
size: 17,
|
||||
),
|
||||
label: Text(
|
||||
isTaken ? '撤销' : '打卡',
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
bool _isTaken(Map<String, dynamic> dose) {
|
||||
return dose['status']?.toString().toLowerCase() == 'taken';
|
||||
}
|
||||
|
||||
String _formatTime(String raw) {
|
||||
final value = raw.trim();
|
||||
if (value.length >= 5 && value[2] == ':') return value.substring(0, 5);
|
||||
final parsed = DateTime.tryParse(value);
|
||||
if (parsed == null) return value;
|
||||
final hour = parsed.hour.toString().padLeft(2, '0');
|
||||
final minute = parsed.minute.toString().padLeft(2, '0');
|
||||
return '$hour:$minute';
|
||||
}
|
||||
|
||||
@@ -139,7 +139,10 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
Widget build(BuildContext context) {
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
leading: IconButton(icon: const Icon(Icons.arrow_back), onPressed: () => popRoute(ref)),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: Text(widget.id != null ? '编辑用药' : '添加用药'),
|
||||
),
|
||||
body: ListView(
|
||||
@@ -164,7 +167,13 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
_label('每日服药次数'), const SizedBox(height: 8),
|
||||
GestureDetector(
|
||||
onTap: () async {
|
||||
final n = await showAppCountPicker(context, initialValue: _timesPerDay, min: 1, max: 4, label: ' 次');
|
||||
final n = await showAppCountPicker(
|
||||
context,
|
||||
initialValue: _timesPerDay,
|
||||
min: 1,
|
||||
max: 4,
|
||||
label: ' 次',
|
||||
);
|
||||
if (n != null) _updateTimes(n);
|
||||
},
|
||||
child: Container(
|
||||
@@ -333,7 +342,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Text(
|
||||
'${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}',
|
||||
_displayDate(val),
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
),
|
||||
@@ -370,7 +379,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
val != null ? '${val.year} ${val.month.toString().padLeft(2, '0')} ${val.day.toString().padLeft(2, '0')}' : '不设置',
|
||||
val != null ? _displayDate(val) : '不设置',
|
||||
style: TextStyle(
|
||||
fontSize: 18,
|
||||
color: val != null ? null : AppTheme.textHint,
|
||||
@@ -393,3 +402,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
String _displayDate(DateTime date) {
|
||||
return '${date.year}/${date.month.toString().padLeft(2, '0')}/${date.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
@@ -75,11 +75,12 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 88),
|
||||
children: [
|
||||
EnterpriseHeader(
|
||||
title: '用药管理',
|
||||
title: '今日用药概览',
|
||||
subtitle: '集中管理服药计划、剂量和每日打卡状态',
|
||||
icon: Icons.medication_outlined,
|
||||
color: _medBlue,
|
||||
accent: _medCyan,
|
||||
showIcon: false,
|
||||
stats: [
|
||||
EnterpriseStat(
|
||||
label: '药品总数',
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:shadcn_ui/shadcn_ui.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/data_providers.dart';
|
||||
import '../../services/in_app_notification_service.dart';
|
||||
@@ -20,6 +21,7 @@ class _NotificationCenterPageState
|
||||
InAppNotificationHistory? _history;
|
||||
bool _loading = true;
|
||||
bool _markingAll = false;
|
||||
bool _showEarlier = false;
|
||||
String? _error;
|
||||
|
||||
InAppNotificationService get _service =>
|
||||
@@ -47,12 +49,11 @@ class _NotificationCenterPageState
|
||||
});
|
||||
ref.invalidate(notificationUnreadCountProvider);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_error = '$error';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_error = '$error';
|
||||
_loading = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,7 +147,7 @@ class _NotificationCenterPageState
|
||||
final unread = _history?.unreadCount ?? 0;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
backgroundColor: const Color(0xFFF6F8FC),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
@@ -160,45 +161,32 @@ class _NotificationCenterPageState
|
||||
'通知中心',
|
||||
style: TextStyle(
|
||||
fontSize: 19,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
centerTitle: true,
|
||||
actions: [
|
||||
if (unread > 0)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4),
|
||||
child: TextButton(
|
||||
onPressed: _markingAll ? null : _markAllRead,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: AppColors.textPrimary,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10),
|
||||
),
|
||||
child: _markingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
)
|
||||
: const Text(
|
||||
'全部已读',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _markingAll ? null : _markAllRead,
|
||||
child: _markingAll
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Text(
|
||||
'全部已读',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
tooltip: '通知设置',
|
||||
icon: const Icon(
|
||||
Icons.settings_outlined,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
icon: const Icon(Icons.settings_outlined),
|
||||
onPressed: () => pushRoute(ref, 'notificationPrefs'),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
@@ -206,16 +194,14 @@ class _NotificationCenterPageState
|
||||
),
|
||||
body: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
color: const Color(0xFF6366F1),
|
||||
color: AppColors.primary,
|
||||
child: _buildBody(items, unread),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(List<InAppNotification> items, int unread) {
|
||||
if (_loading) {
|
||||
return const Center(child: CircularProgressIndicator());
|
||||
}
|
||||
if (_loading) return const Center(child: CircularProgressIndicator());
|
||||
if (_error != null) {
|
||||
return _MessageState(
|
||||
icon: LucideIcons.wifiOff,
|
||||
@@ -250,7 +236,6 @@ class _NotificationCenterPageState
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 32),
|
||||
children: [
|
||||
if (unread > 0) _UnreadHint(unreadCount: unread),
|
||||
const SizedBox(height: 8),
|
||||
if (today.isNotEmpty) ...[
|
||||
const _SectionTitle('今天'),
|
||||
const SizedBox(height: 10),
|
||||
@@ -258,9 +243,14 @@ class _NotificationCenterPageState
|
||||
],
|
||||
if (earlier.isNotEmpty) ...[
|
||||
if (today.isNotEmpty) const SizedBox(height: 18),
|
||||
const _SectionTitle('更早'),
|
||||
_CollapsibleSectionTitle(
|
||||
text: '更早',
|
||||
count: earlier.length,
|
||||
expanded: _showEarlier,
|
||||
onTap: () => setState(() => _showEarlier = !_showEarlier),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
...earlier.map(_buildDismissible),
|
||||
if (_showEarlier) ...earlier.map(_buildDismissible),
|
||||
],
|
||||
],
|
||||
);
|
||||
@@ -275,12 +265,8 @@ class _NotificationCenterPageState
|
||||
alignment: Alignment.centerRight,
|
||||
padding: const EdgeInsets.only(right: 26),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.centerLeft,
|
||||
end: Alignment.centerRight,
|
||||
colors: [AppColors.error.withValues(alpha: 0.6), AppColors.error],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
color: AppColors.error,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
@@ -292,106 +278,157 @@ class _NotificationCenterPageState
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
onDismissed: (_) => _delete(item),
|
||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||
child: SizedBox(
|
||||
height: 98,
|
||||
child: _NotificationCard(item: item, onTap: () => _open(item)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// 顶部一行精致提示——只在有未读时显示,不抢眼
|
||||
class _UnreadHint extends StatelessWidget {
|
||||
final int unreadCount;
|
||||
|
||||
const _UnreadHint({required this.unreadCount});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 4, 0, 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFFF97316),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 2, 0, 14),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: const Color(0xFFFDE68A)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(LucideIcons.bellRing, size: 18, color: Color(0xFFF97316)),
|
||||
const SizedBox(width: 9),
|
||||
Text(
|
||||
'你有 $unreadCount 条未读消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF92400E),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'你有 $unreadCount 条未读消息',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: Color(0xFF92400E),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const _SectionTitle(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 12, 0, 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFE5E7EB)),
|
||||
),
|
||||
Widget build(BuildContext context) => _SectionShell(
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 8,
|
||||
height: 8,
|
||||
decoration: BoxDecoration(
|
||||
gradient: AppColors.doctorGradient,
|
||||
borderRadius: BorderRadius.circular(99),
|
||||
),
|
||||
const Icon(
|
||||
LucideIcons.calendarDays,
|
||||
size: 18,
|
||||
color: AppColors.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
text,
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w800,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Container(
|
||||
height: 1,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [
|
||||
const Color(0xFFE5E7EB),
|
||||
const Color(0xFFE5E7EB).withValues(alpha: 0),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SectionShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
|
||||
const _SectionShell({required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(0, 6, 0, 2),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.035),
|
||||
blurRadius: 10,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
|
||||
class _CollapsibleSectionTitle extends StatelessWidget {
|
||||
final String text;
|
||||
final int count;
|
||||
final bool expanded;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _CollapsibleSectionTitle({
|
||||
required this.text,
|
||||
required this.count,
|
||||
required this.expanded,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: onTap,
|
||||
child: _SectionShell(
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
LucideIcons.history,
|
||||
size: 18,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'$text · $count',
|
||||
style: const TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
expanded ? '收起' : '展开',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Icon(
|
||||
expanded ? Icons.keyboard_arrow_up : Icons.keyboard_arrow_down,
|
||||
size: 22,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -406,80 +443,113 @@ class _NotificationCard extends StatelessWidget {
|
||||
final visual = _NotificationVisual.of(item);
|
||||
return Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
elevation: 0,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
height: 98,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: item.isRead
|
||||
? const Color(0xFFF1F5F9)
|
||||
: visual.color.withValues(alpha: 0.24),
|
||||
width: 1,
|
||||
? AppColors.borderLight
|
||||
: visual.color.withValues(alpha: 0.32),
|
||||
width: 1.1,
|
||||
),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0F172A).withValues(alpha: 0.08),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
BoxShadow(
|
||||
color: visual.color.withValues(alpha: item.isRead ? 0 : 0.08),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
child: Stack(
|
||||
children: [
|
||||
// 图标
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: visual.color.withValues(alpha: 0.12),
|
||||
),
|
||||
),
|
||||
child: Icon(visual.icon, size: 23, color: visual.color),
|
||||
),
|
||||
if (!item.isRead)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEF4444),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 1.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(14, 13, 12, 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Row(
|
||||
Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
visual.color.withValues(alpha: 0.18),
|
||||
visual.lightColor,
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(
|
||||
color: visual.color.withValues(alpha: 0.22),
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
visual.icon,
|
||||
size: 24,
|
||||
color: visual.color,
|
||||
),
|
||||
),
|
||||
if (!item.isRead)
|
||||
Positioned(
|
||||
top: -2,
|
||||
right: -2,
|
||||
child: Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.error,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(
|
||||
color: Colors.white,
|
||||
width: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 7,
|
||||
vertical: 3,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: visual.lightColor,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
visual.label,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: visual.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_formatTime(item.createdAt),
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
item.title,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
@@ -489,54 +559,35 @@ class _NotificationCard extends StatelessWidget {
|
||||
? FontWeight.w700
|
||||
: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
height: 1.3,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 8,
|
||||
vertical: 4,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
_formatTime(item.createdAt),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
item.message,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 14,
|
||||
height: 1.25,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
item.message,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: AppColors.textSecondary,
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.actionType != null) ...[
|
||||
const SizedBox(width: 6),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
if (item.actionType != null) ...[
|
||||
const SizedBox(width: 4),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -555,8 +606,8 @@ class _NotificationCard extends StatelessWidget {
|
||||
local.day == now.day) {
|
||||
return time;
|
||||
}
|
||||
if (local.year == now.year) return '${local.month}月${local.day}日 $time';
|
||||
return '${local.year}年${local.month}月${local.day}日 $time';
|
||||
if (local.year == now.year) return '${local.month}/${local.day} $time';
|
||||
return '${local.year}/${local.month}/${local.day} $time';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -573,40 +624,38 @@ class _NotificationVisual {
|
||||
case 'exercise':
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.activity,
|
||||
Color(0xFF60A5FA),
|
||||
Color(0xFFEFF6FF),
|
||||
Color(0xFF16A34A),
|
||||
Color(0xFFEAF8EF),
|
||||
'运动',
|
||||
);
|
||||
case 'health':
|
||||
if (item.severity == 'critical') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'紧急',
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.heartPulse,
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFFFEF3C7),
|
||||
'健康',
|
||||
);
|
||||
return item.severity == 'critical'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.triangleAlert,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'紧急',
|
||||
)
|
||||
: const _NotificationVisual(
|
||||
LucideIcons.heartPulse,
|
||||
Color(0xFFF59E0B),
|
||||
Color(0xFFFEF3C7),
|
||||
'健康',
|
||||
);
|
||||
case 'report':
|
||||
if (item.severity == 'error') {
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'报告',
|
||||
);
|
||||
}
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.fileCheck2,
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFFEDE9FE),
|
||||
'报告',
|
||||
);
|
||||
return item.severity == 'error'
|
||||
? const _NotificationVisual(
|
||||
LucideIcons.fileWarning,
|
||||
Color(0xFFEF4444),
|
||||
Color(0xFFFEE2E2),
|
||||
'报告',
|
||||
)
|
||||
: const _NotificationVisual(
|
||||
LucideIcons.fileCheck2,
|
||||
Color(0xFF8B5CF6),
|
||||
Color(0xFFEDE9FE),
|
||||
'报告',
|
||||
);
|
||||
default:
|
||||
return const _NotificationVisual(
|
||||
LucideIcons.pill,
|
||||
@@ -642,15 +691,9 @@ class _MessageState extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 36),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
border: Border.all(color: const Color(0xFFE9EEF5)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF101828).withValues(alpha: 0.04),
|
||||
blurRadius: 14,
|
||||
offset: const Offset(0, 4),
|
||||
),
|
||||
],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
@@ -658,14 +701,10 @@ class _MessageState extends StatelessWidget {
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [Color(0xFFEEF2FF), Color(0xFFF5F3FF)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
color: AppColors.primaryLight,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: Icon(icon, size: 32, color: const Color(0xFF6366F1)),
|
||||
child: Icon(icon, size: 32, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
@@ -681,7 +720,7 @@ class _MessageState extends StatelessWidget {
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
fontSize: 14,
|
||||
height: 1.5,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
@@ -691,7 +730,7 @@ class _MessageState extends StatelessWidget {
|
||||
FilledButton(
|
||||
onPressed: onPressed,
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF6366F1),
|
||||
backgroundColor: AppColors.primary,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 28,
|
||||
vertical: 12,
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/app_colors.dart';
|
||||
import '../../core/app_theme.dart';
|
||||
import '../../core/navigation_provider.dart';
|
||||
import '../../providers/auth_provider.dart';
|
||||
import '../../widgets/enterprise_widgets.dart';
|
||||
|
||||
class ProfilePage extends ConsumerWidget {
|
||||
const ProfilePage({super.key});
|
||||
@@ -18,102 +18,33 @@ class ProfilePage extends ConsumerWidget {
|
||||
|
||||
return GradientScaffold(
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.86),
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 19),
|
||||
onPressed: () => popRoute(ref),
|
||||
),
|
||||
title: const Text(
|
||||
'个人信息',
|
||||
style: TextStyle(fontSize: 20, fontWeight: FontWeight.w800),
|
||||
),
|
||||
title: const Text('个人信息'),
|
||||
centerTitle: true,
|
||||
),
|
||||
body: SafeArea(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 34),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
EnterpriseHeader(
|
||||
title: name,
|
||||
subtitle: phone.isNotEmpty ? phone : '未绑定手机',
|
||||
icon: Icons.person_rounded,
|
||||
color: const Color(0xFF38BDF8),
|
||||
accent: const Color(0xFF8B5CF6),
|
||||
stats: const [
|
||||
EnterpriseStat(
|
||||
label: '账号状态',
|
||||
value: '已登录',
|
||||
icon: Icons.verified_user_rounded,
|
||||
),
|
||||
EnterpriseStat(
|
||||
label: '健康资料',
|
||||
value: '可维护',
|
||||
icon: Icons.folder_shared_rounded,
|
||||
),
|
||||
],
|
||||
trailing: _AvatarBadge(avatarUrl: user?.avatarUrl),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_InfoPanel(
|
||||
children: [
|
||||
_InfoRow(
|
||||
icon: Icons.badge_rounded,
|
||||
label: '姓名',
|
||||
value: name,
|
||||
colors: const [Color(0xFF7DD3FC), Color(0xFF38BDF8)],
|
||||
),
|
||||
const _SoftDivider(),
|
||||
_InfoRow(
|
||||
icon: Icons.phone_iphone_rounded,
|
||||
label: '手机号',
|
||||
value: phone.isNotEmpty ? phone : '未绑定手机',
|
||||
colors: const [Color(0xFFFBCFE8), Color(0xFFF472B6)],
|
||||
),
|
||||
const _SoftDivider(),
|
||||
_InfoRow(
|
||||
icon: Icons.folder_shared_rounded,
|
||||
label: '健康档案',
|
||||
value: '查看和维护基础健康资料',
|
||||
colors: const [Color(0xFFC4B5FD), Color(0xFF8B5CF6)],
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
_InfoPanel(
|
||||
children: [
|
||||
_InfoRow(
|
||||
icon: Icons.verified_user_rounded,
|
||||
label: '隐私保护',
|
||||
value: '健康数据仅用于个人健康管理',
|
||||
colors: const [Color(0xFFFFB4A2), Color(0xFFFB7185)],
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _logout(context, ref),
|
||||
icon: const Icon(Icons.logout_rounded, size: 19),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: BorderSide(
|
||||
color: AppColors.error.withValues(alpha: 0.34),
|
||||
),
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
textStyle: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 32),
|
||||
children: [
|
||||
_AccountCard(
|
||||
name: name,
|
||||
phone: phone.isNotEmpty ? phone : '未绑定手机号',
|
||||
avatarUrl: user?.avatarUrl,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_ActionTile(
|
||||
icon: Icons.folder_shared_outlined,
|
||||
title: '健康档案',
|
||||
subtitle: '维护个人资料、病史、手术和过敏信息',
|
||||
color: const Color(0xFF8B5CF6),
|
||||
onTap: () => pushRoute(ref, 'healthArchive'),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
_LogoutButton(onPressed: () => _logout(context, ref)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -124,7 +55,7 @@ class ProfilePage extends ConsumerWidget {
|
||||
context: context,
|
||||
builder: (ctx) => AlertDialog(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(AppTheme.rXl),
|
||||
borderRadius: BorderRadius.circular(AppTheme.rLg),
|
||||
),
|
||||
title: const Text('退出登录'),
|
||||
content: const Text('确定退出当前账号?'),
|
||||
@@ -147,99 +78,126 @@ class ProfilePage extends ConsumerWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _AvatarBadge extends StatelessWidget {
|
||||
class _AccountCard extends StatelessWidget {
|
||||
final String name;
|
||||
final String phone;
|
||||
final String? avatarUrl;
|
||||
const _AvatarBadge({required this.avatarUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 50,
|
||||
height: 50,
|
||||
padding: const EdgeInsets.all(2),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: avatarUrl != null
|
||||
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
||||
: const ColoredBox(
|
||||
color: Colors.white,
|
||||
child: Icon(
|
||||
Icons.person_rounded,
|
||||
color: Color(0xFF38BDF8),
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoPanel extends StatelessWidget {
|
||||
final List<Widget> children;
|
||||
const _InfoPanel({required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
boxShadow: AppColors.cardShadowLight,
|
||||
),
|
||||
child: Column(children: children),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final List<Color> colors;
|
||||
final VoidCallback? onTap;
|
||||
const _InfoRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.colors,
|
||||
this.onTap,
|
||||
const _AccountCard({
|
||||
required this.name,
|
||||
required this.phone,
|
||||
required this.avatarUrl,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_Avatar(avatarUrl: avatarUrl),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 5),
|
||||
Text(
|
||||
phone,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final String? avatarUrl;
|
||||
|
||||
const _Avatar({required this.avatarUrl});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
width: 58,
|
||||
height: 58,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.iconBg,
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(color: AppColors.borderLight),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: avatarUrl != null && avatarUrl!.isNotEmpty
|
||||
? Image.network(avatarUrl!, fit: BoxFit.cover)
|
||||
: const Icon(
|
||||
Icons.person_rounded,
|
||||
color: AppColors.blueMeasure,
|
||||
size: 34,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ActionTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Material(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 12),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(15),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: AppColors.border, width: 1.1),
|
||||
boxShadow: [AppTheme.shadowLight],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: colors,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: colors.last.withValues(alpha: 0.18),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 5),
|
||||
),
|
||||
],
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 22),
|
||||
child: Icon(icon, color: color, size: 24),
|
||||
),
|
||||
const SizedBox(width: 13),
|
||||
Expanded(
|
||||
@@ -247,45 +205,54 @@ class _InfoRow extends StatelessWidget {
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
label,
|
||||
title,
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: AppColors.textHint,
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
value,
|
||||
subtitle,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.w800,
|
||||
color: AppColors.textPrimary,
|
||||
fontSize: 14,
|
||||
color: AppColors.textSecondary,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (onTap != null)
|
||||
const Icon(
|
||||
Icons.arrow_forward_ios_rounded,
|
||||
size: 16,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
const Icon(
|
||||
Icons.chevron_right_rounded,
|
||||
size: 22,
|
||||
color: AppColors.textHint,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _SoftDivider extends StatelessWidget {
|
||||
const _SoftDivider();
|
||||
class _LogoutButton extends StatelessWidget {
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _LogoutButton({required this.onPressed});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Divider(height: 1, color: AppColors.divider);
|
||||
}
|
||||
Widget build(BuildContext context) => OutlinedButton.icon(
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.logout_rounded, size: 19),
|
||||
label: const Text('退出登录'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppColors.error,
|
||||
side: BorderSide(color: AppColors.error.withValues(alpha: 0.34)),
|
||||
minimumSize: const Size.fromHeight(52),
|
||||
textStyle: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,6 +2,7 @@ import 'dart:convert';
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:file_picker/file_picker.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
@@ -404,11 +405,12 @@ class ReportListPage extends ConsumerWidget {
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
EnterpriseHeader(
|
||||
title: '报告管理',
|
||||
title: '报告处理概览',
|
||||
subtitle: '上传检查报告后自动进行 AI 结构化解读',
|
||||
icon: Icons.description_outlined,
|
||||
color: _reportBlue,
|
||||
accent: _reportCyan,
|
||||
showIcon: false,
|
||||
stats: [
|
||||
EnterpriseStat(
|
||||
label: '报告总数',
|
||||
@@ -524,6 +526,25 @@ class ReportListPage extends ConsumerWidget {
|
||||
}
|
||||
},
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.picture_as_pdf_outlined,
|
||||
color: _reportBlue,
|
||||
),
|
||||
title: const Text('上传 PDF', style: TextStyle(fontSize: 17)),
|
||||
onTap: () async {
|
||||
Navigator.pop(ctx);
|
||||
final result = await FilePicker.platform.pickFiles(
|
||||
type: FileType.custom,
|
||||
allowedExtensions: ['pdf'],
|
||||
withData: false,
|
||||
);
|
||||
if (result == null || result.files.isEmpty) return;
|
||||
final path = result.files.first.path;
|
||||
if (path == null || path.isEmpty) return;
|
||||
ref.read(reportProvider.notifier).uploadFile(path);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user