feat: AI Agent 处理器扩展 + 服药打卡确认链路 + AI 气泡配色微调 + 数据刷新 providers 抽离 + 多端页面细节调整

This commit is contained in:
MingNian
2026-07-20 17:12:00 +08:00
parent 9cea41705e
commit 0cb5b8e85a
45 changed files with 1435 additions and 276 deletions

View File

@@ -292,13 +292,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (ctx) => Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(ctx).viewInsets.bottom,
left: 20,
right: 20,
top: 20,
),
builder: (ctx) => _KeyboardInsetPadding(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -415,6 +409,7 @@ class _TrendPageState extends ConsumerState<TrendPage> {
@override
Widget build(BuildContext context) {
return GradientScaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back),
@@ -1304,6 +1299,25 @@ class _TrendPageState extends ConsumerState<TrendPage> {
}
}
class _KeyboardInsetPadding extends StatelessWidget {
final Widget child;
const _KeyboardInsetPadding({required this.child});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.viewInsetsOf(context).bottom,
left: 20,
right: 20,
top: 20,
),
child: RepaintBoundary(child: child),
);
}
}
class _StatisticItem extends StatelessWidget {
final String label;
final String value;

View File

@@ -8,6 +8,7 @@ import '../../core/app_colors.dart';
import '../../core/app_design_tokens.dart';
import '../../core/app_theme.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_refresh_providers.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/common_widgets.dart';
import '../../widgets/app_empty_state.dart';
@@ -803,6 +804,7 @@ class _DietCapturePageState extends ConsumerState<DietCapturePage> {
Future<void> _saveDietRecord() async {
try {
await ref.read(dietProvider.notifier).saveRecord();
ref.read(dietDataRefreshSignalProvider.notifier).trigger();
if (mounted) popRoute(ref);
} on DietFoodValidationException catch (e) {
if (mounted) {

View File

@@ -3,11 +3,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
import '../../widgets/backoffice_ui.dart';
import '../doctor/doctor_home_page.dart' show doctorPageProvider;
final _dashboardProvider = FutureProvider<Map<String, dynamic>?>((ref) async {
ref.watch(userSessionIdentityProvider);
ref.watch(doctorDashboardRefreshSignalProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/dashboard');
return res.data['data'] as Map<String, dynamic>?;

View File

@@ -274,6 +274,7 @@ class _DoctorFollowUpEditPageState
}
if (mounted) {
ref.read(doctorFollowupsRefreshSignalProvider.notifier).trigger();
ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger();
_snack(isEdit ? '修改成功' : '创建成功', ok: true);
popRoute(ref);
}

View File

@@ -211,6 +211,12 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
.markDone(
f['id']?.toString() ?? '',
);
ref
.read(
doctorDashboardRefreshSignalProvider
.notifier,
)
.trigger();
},
child: const Text(
'完成',
@@ -232,6 +238,12 @@ class _DoctorFollowupsPageState extends ConsumerState<DoctorFollowupsPage> {
.remove(
f['id']?.toString() ?? '',
);
ref
.read(
doctorDashboardRefreshSignalProvider
.notifier,
)
.trigger();
},
child: const Text(
'删除',

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/backoffice_ui.dart';
@@ -120,6 +121,7 @@ class _DoctorProfileEditPageState extends ConsumerState<DoctorProfileEditPage> {
},
);
ref.invalidate(_docProfileProvider);
ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(context, '保存成功', type: AppToastType.success);
}

View File

@@ -4,6 +4,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
import '../../utils/backoffice_formatters.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/backoffice_ui.dart';
@@ -75,6 +76,8 @@ class _DoctorReportDetailPageState
if (!mounted) return;
setState(() => _submitted = true);
ref.invalidate(_reportDetailProvider(widget.id));
ref.read(doctorReportsRefreshSignalProvider.notifier).trigger();
ref.read(doctorDashboardRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(context, '审核提交成功', type: AppToastType.success);
}

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../core/app_colors.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/backoffice_refresh_providers.dart';
import '../../utils/backoffice_formatters.dart';
import '../../widgets/backoffice_ui.dart';
@@ -10,6 +11,7 @@ final _reportsProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(doctorReportsRefreshSignalProvider);
final api = ref.read(apiClientProvider);
final res = await api.get('/api/doctor/reports');
return (res.data['data'] as List?)?.cast<Map<String, dynamic>>() ?? [];

View File

@@ -8,6 +8,7 @@ import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../providers/data_refresh_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/app_toast.dart';
@@ -34,8 +35,7 @@ class _EnterpriseExercisePlanPageState
setState(() => _busyItems.add(itemId));
try {
await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
ref.invalidate(exercisePlansProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
} catch (error) {
if (mounted) {
AppToast.show(
@@ -73,8 +73,7 @@ class _EnterpriseExercisePlanPageState
setState(() => _deletingPlans.add(id));
try {
await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider);
ref.invalidate(exercisePlansProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(context, '运动计划已删除', type: AppToastType.success);
}
@@ -421,12 +420,12 @@ class _ExercisePlanRow extends StatelessWidget {
width: 40,
height: 40,
decoration: BoxDecoration(
color: AppColors.exerciseLight,
color: _phaseColor(phase).withValues(alpha: 0.10),
borderRadius: AppRadius.smBorder,
),
child: Icon(
AppModuleVisuals.exercise.icon,
color: AppColors.exercise,
color: _phaseColor(phase),
size: 22,
),
),

View File

@@ -170,18 +170,30 @@ class _HomePageState extends ConsumerState<HomePage>
children: [
_buildHeader(user),
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: _startConversationDrawerDrag,
onHorizontalDragUpdate: _updateConversationDrawerDrag,
onHorizontalDragEnd: _endConversationDrawerDrag,
onHorizontalDragCancel: () => _conversationDragDistance = 0,
child: RepaintBoundary(
child: _HomeMessages(scrollCtrl: _scrollCtrl),
child: ClipRect(
child: KeyboardTranslate(
child: Column(
children: [
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: _startConversationDrawerDrag,
onHorizontalDragUpdate:
_updateConversationDrawerDrag,
onHorizontalDragEnd: _endConversationDrawerDrag,
onHorizontalDragCancel: () =>
_conversationDragDistance = 0,
child: RepaintBoundary(
child: _HomeMessages(scrollCtrl: _scrollCtrl),
),
),
),
_buildBottomBar(context),
],
),
),
),
),
KeyboardLift(child: _buildBottomBar(context)),
],
),
),

View File

@@ -420,6 +420,8 @@ class ChatMessagesView extends ConsumerWidget {
) {
final meta = msg.metadata ?? <String, dynamic>{};
final backendType = meta['type'] as String? ?? '';
final medicationOperation = meta['operation']?.toString() ?? 'create';
final isMedicationCheckIn = medicationOperation == 'confirm';
final screenWidth = MediaQuery.sizeOf(context).width;
// 识别是哪种数据类型
@@ -449,7 +451,7 @@ class ChatMessagesView extends ConsumerWidget {
if (isMedication) {
// 药品录入 — 主展示区已显示药名+剂量,详情只列额外信息
title = '药品录入确认';
title = isMedicationCheckIn ? '服药打卡确认' : '药品录入确认';
titleIcon = Icons.medication_liquid_outlined;
mainLabel = meta['name'] as String? ?? '';
mainValue = meta['dosage'] as String? ?? '';
@@ -834,7 +836,9 @@ class ChatMessagesView extends ConsumerWidget {
),
const SizedBox(width: 8),
Text(
msg.isReadOnly ? '历史记录' : '录入成功',
msg.isReadOnly
? '历史记录'
: (isMedicationCheckIn ? '打卡成功' : '录入成功'),
style: const TextStyle(
fontSize: 19,
fontWeight: FontWeight.w700,
@@ -882,9 +886,9 @@ class ChatMessagesView extends ConsumerWidget {
),
),
const SizedBox(width: 10),
const Text(
'确认录入',
style: TextStyle(
Text(
isMedicationCheckIn ? '确认打卡' : '确认录入',
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: AppColors.textPrimary,
@@ -1005,7 +1009,7 @@ class ChatMessagesView extends ConsumerWidget {
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
decoration: BoxDecoration(
color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFF8F8FF),
color: isUser ? const Color(0xFF5F56FF) : const Color(0xFFFAFAFF),
borderRadius: BorderRadius.only(
topLeft: Radius.circular(isUser ? 18.6 : 3),
topRight: Radius.circular(isUser ? 3 : 18.6),

View File

@@ -10,6 +10,7 @@ import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/auth_provider.dart';
import '../../providers/data_providers.dart';
import '../../providers/data_refresh_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
@@ -62,8 +63,7 @@ class _MedicationCheckInPageState extends ConsumerState<MedicationCheckInPage> {
throw const ApiException('该次服药已经打卡');
}
}
ref.invalidate(medicationReminderProvider);
ref.invalidate(medicationListProvider);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
_load();
} catch (error) {
if (mounted) {

View File

@@ -8,6 +8,7 @@ import '../../core/app_design_tokens.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../providers/data_refresh_providers.dart';
import '../../widgets/app_error_state.dart';
import '../../widgets/app_toast.dart';
import '../../widgets/common_widgets.dart';
@@ -117,8 +118,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
} else {
await service.create(payload);
}
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(
context,
@@ -164,8 +164,7 @@ class _MedicationEditPageState extends ConsumerState<MedicationEditPage> {
setState(() => _saving = true);
try {
await ref.read(medicationServiceProvider).deleteMedication(widget.id!);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(context, '用药已删除', type: AppToastType.success);
popRoute(ref);

View File

@@ -9,6 +9,7 @@ import '../../core/app_module_visuals.dart';
import '../../core/app_theme.dart';
import '../../core/navigation_provider.dart';
import '../../providers/data_providers.dart';
import '../../providers/data_refresh_providers.dart';
import '../../widgets/app_empty_state.dart';
import '../../widgets/app_future_view.dart';
import '../../widgets/app_toast.dart';
@@ -52,8 +53,7 @@ class _MedicationListPageState extends ConsumerState<MedicationListPage> {
setState(() => _deleting.add(id));
try {
await ref.read(medicationServiceProvider).deleteMedication(id);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
if (mounted) AppToast.show(context, '用药已删除', type: AppToastType.success);
} catch (error) {
if (mounted) {

View File

@@ -9,6 +9,7 @@ import '../core/app_theme.dart';
import '../core/navigation_provider.dart';
import '../providers/auth_provider.dart';
import '../providers/data_providers.dart';
import '../providers/data_refresh_providers.dart';
import '../widgets/common_widgets.dart';
import '../widgets/app_empty_state.dart';
import '../widgets/app_error_state.dart';
@@ -64,7 +65,7 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
setState(() => _data.removeAt(index));
try {
await ref.read(dietServiceProvider).deleteRecord(id);
ref.invalidate(dietRecordsProvider);
ref.read(dietDataRefreshSignalProvider.notifier).trigger();
if (mounted) {
AppToast.show(context, '已删除', type: AppToastType.success);
}
@@ -100,6 +101,9 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
@override
Widget build(BuildContext context) {
ref.listen(dietDataRefreshSignalProvider, (previous, next) {
if (previous != null && previous != next) _refresh();
});
if (_loading) {
return GradientScaffold(
appBar: AppBar(
@@ -451,7 +455,9 @@ class _DietRecordListPageState extends ConsumerState<DietRecordListPage> {
});
if (!ctx.mounted) return;
Navigator.pop(ctx);
await _refresh();
ref
.read(dietDataRefreshSignalProvider.notifier)
.trigger();
} catch (_) {
if (!ctx.mounted) return;
setDialogState(() => saving = false);
@@ -1047,7 +1053,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
setState(() => _busyItems.add(itemId));
try {
await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
_load();
} catch (e) {
debugPrint('[ExercisePlan] 打卡失败: $e');
@@ -1061,7 +1067,7 @@ class _ExercisePlanPageState extends ConsumerState<ExercisePlanPage> {
Future<void> _deletePlan(String id) async {
await ref.read(exerciseServiceProvider).deletePlan(id);
ref.invalidate(currentExercisePlanProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
_load();
}
@@ -1657,7 +1663,7 @@ class _ExercisePlanDetailPageState
setState(() => _busyItems.add(itemId));
try {
await ref.read(exerciseServiceProvider).checkIn(itemId);
ref.invalidate(currentExercisePlanProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
_load();
} catch (e) {
debugPrint('[ExercisePlanDetail] 打卡失败: $e');
@@ -2182,7 +2188,7 @@ class _ExercisePlanCreatePageState
'reminderTime':
'${_reminderTime.hour.toString().padLeft(2, '0')}:${_reminderTime.minute.toString().padLeft(2, '0')}',
});
ref.invalidate(currentExercisePlanProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
if (mounted) {
popRoute(ref);
}
@@ -3805,8 +3811,19 @@ class _HealthCalendarPageState extends ConsumerState<HealthCalendarPage> {
_loadMonth();
}
void _refreshScheduleData() {
_dayCache.clear();
_loadMonth();
}
@override
Widget build(BuildContext context) {
ref.listen(exerciseDataRefreshSignalProvider, (previous, next) {
if (previous != null && previous != next) _refreshScheduleData();
});
ref.listen(medicationDataRefreshSignalProvider, (previous, next) {
if (previous != null && previous != next) _refreshScheduleData();
});
return GradientScaffold(
appBar: AppBar(
backgroundColor: Colors.white,

View File

@@ -12,3 +12,9 @@ final adminDoctorsRefreshSignalProvider =
final doctorFollowupsRefreshSignalProvider =
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
final doctorReportsRefreshSignalProvider =
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);
final doctorDashboardRefreshSignalProvider =
NotifierProvider<BackofficeRefreshSignal, int>(BackofficeRefreshSignal.new);

View File

@@ -5,10 +5,27 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'conversation_history_provider.dart';
import 'data_providers.dart';
import 'data_refresh_providers.dart';
import '../utils/sse_handler.dart';
enum MessageType { text, dataConfirm, agentWelcome, taskCard }
List<ChatMessage> prependTodayHealthCard(
List<ChatMessage> messages, {
DateTime? now,
}) {
return [
ChatMessage(
id: 'task_card',
role: 'assistant',
content: '',
createdAt: now ?? DateTime.now(),
type: MessageType.taskCard,
),
...messages,
];
}
class ChatMessage {
final String id;
final String role;
@@ -133,10 +150,9 @@ class ChatNotifier extends Notifier<ChatState> {
msgs[i].confirmed = true;
state = state.copyWith(messages: msgs);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.read(medicationDataRefreshSignalProvider.notifier).trigger();
ref.invalidate(latestHealthProvider);
ref.invalidate(currentExercisePlanProvider);
ref.read(exerciseDataRefreshSignalProvider.notifier).trigger();
return null;
}
@@ -161,18 +177,7 @@ class ChatNotifier extends Notifier<ChatState> {
void insertTaskCard() {
if (state.messages.any((m) => m.type == MessageType.taskCard)) return;
state = state.copyWith(
messages: [
ChatMessage(
id: 'task_card',
role: 'assistant',
content: '',
createdAt: DateTime.now(),
type: MessageType.taskCard,
),
...state.messages,
],
);
state = state.copyWith(messages: prependTodayHealthCard(state.messages));
}
Future<String?> loadConversation(String convId) async {
@@ -212,7 +217,7 @@ class ChatNotifier extends Notifier<ChatState> {
}).toList();
state = ChatState(
messages: messages,
messages: prependTodayHealthCard(messages),
conversationId: convId,
activeAgent: ActiveAgent.default_,
isViewingHistory: true,

View File

@@ -1,5 +1,6 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'auth_provider.dart';
import 'data_refresh_providers.dart';
import '../services/health_service.dart';
import '../services/admin_service.dart';
import '../services/in_app_notification_service.dart';
@@ -63,6 +64,7 @@ final medicationListProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(medicationDataRefreshSignalProvider);
final service = ref.watch(medicationServiceProvider);
return service.getList();
});
@@ -71,6 +73,7 @@ final exercisePlansProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(exerciseDataRefreshSignalProvider);
return ref.watch(exerciseServiceProvider).getPlans();
});
@@ -85,6 +88,7 @@ final dietRecordsProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(dietDataRefreshSignalProvider);
return ref.watch(dietServiceProvider).getRecords();
});
@@ -92,6 +96,7 @@ final medicationReminderProvider = FutureProvider<List<Map<String, dynamic>>>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(medicationDataRefreshSignalProvider);
final service = ref.watch(medicationServiceProvider);
return service.getReminders();
});
@@ -109,6 +114,7 @@ final currentExercisePlanProvider = FutureProvider<Map<String, dynamic>?>((
ref,
) async {
ref.watch(userSessionIdentityProvider);
ref.watch(exerciseDataRefreshSignalProvider);
final service = ref.watch(exerciseServiceProvider);
return service.getCurrentPlan().timeout(const Duration(seconds: 8));
});

View File

@@ -0,0 +1,18 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
class DataRefreshSignal extends Notifier<int> {
@override
int build() => 0;
void trigger() => state++;
}
final exerciseDataRefreshSignalProvider =
NotifierProvider<DataRefreshSignal, int>(DataRefreshSignal.new);
final medicationDataRefreshSignalProvider =
NotifierProvider<DataRefreshSignal, int>(DataRefreshSignal.new);
final dietDataRefreshSignalProvider = NotifierProvider<DataRefreshSignal, int>(
DataRefreshSignal.new,
);

View File

@@ -15,3 +15,20 @@ class KeyboardLift extends StatelessWidget {
);
}
}
/// 只移动已有前景图层,不在键盘动画期间重新布局子组件。
class KeyboardTranslate extends StatelessWidget {
final Widget child;
const KeyboardTranslate({super.key, required this.child});
@override
Widget build(BuildContext context) {
final bottom = MediaQuery.viewInsetsOf(context).bottom;
return Transform.translate(
offset: Offset(0, -bottom),
transformHitTests: true,
child: RepaintBoundary(child: child),
);
}
}