Files
AI-Health/health_app/lib/app.dart
MingNian b4bc15b9b9 feat: AI 意图路由/草稿存储 + Prompts 模块化 + UI 视觉统一 + AI 同意门 + 资源更新
后端:
- 新增 ai_intent_router 意图路由
- 新增 AiEntryDraft 草稿存储 + EF 迁移
- 新增 Prompts 模块化(global/modules/rag/router)
- AI Agent handlers 扩展

前端:
- 新增 AI 同意门 (ai_consent_gate/provider/details_page)
- HealthMetricVisuals 视觉统一
- 设备扫描/管理页面优化
- agent 插图替换 + 趋势指标图标

配置:
- DeepSeek 模型改 deepseek-v4-flash
- .gitignore 加 artifacts/audit
- build.gradle.kts 签名配置调整
2026-07-28 15:03:38 +08:00

230 lines
7.6 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
import 'core/app_router.dart';
import 'core/app_theme.dart';
import 'core/elder_mode_scope.dart';
import 'core/navigation_provider.dart';
import 'pages/splash_page.dart';
import 'providers/ai_consent_provider.dart';
import 'providers/auth_provider.dart';
import 'providers/data_providers.dart';
import 'providers/elder_mode_provider.dart';
import 'widgets/ai_consent_gate.dart';
/// 健康管家 App 根组件
class HealthApp extends ConsumerWidget {
const HealthApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final auth = ref.watch(authProvider);
final elderMode = ref.watch(elderModeProvider);
final isPatient = auth.isLoggedIn && auth.user?.role == 'User';
final useElderMode = isPatient && elderMode.isEnabled;
final useLargeText = useElderMode && elderMode.preferences.largeTextEnabled;
return MaterialApp(
title: '小脉健康',
debugShowCheckedModeBanner: false,
theme: useElderMode ? AppTheme.elderLightTheme : AppTheme.lightTheme,
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: const [Locale('zh', 'CN'), Locale('zh')],
locale: const Locale('zh'),
home: const _RootNavigator(),
// 注入 ShadTheme + 启动闸门Splash 盖在最上层,直到首页今日健康卡数据就绪
// 外层包一层 GestureDetector点击任意空白处取消输入框焦点全 app 生效)
builder: (context, child) => ElderModeScope(
enabled: useElderMode,
largeTextEnabled: useLargeText,
child: Builder(
builder: (scopeContext) => MediaQuery(
data: MediaQuery.of(
scopeContext,
).copyWith(textScaler: ElderModeScope.textScalerFor(scopeContext)),
child: ShadTheme(
data: AppTheme.shadTheme,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: AiConsentGate(child: _BootGate(child: child!)),
),
),
),
),
),
);
}
}
/// 启动是否就绪auth 判定完成,且(已登录的普通用户)今日健康卡数据已到位。
/// 未登录或医生/管理员无需等待健康卡。
final appReadyProvider = Provider<bool>((ref) {
final auth = ref.watch(authProvider);
if (auth.isLoading) return false; // 还在判登录态 → 继续盖 Splash
if (!auth.isLoggedIn) return true; // 未登录 → 就绪,露出登录页
final currentRoute = ref.watch(routeStackProvider).last.name;
if (currentRoute == 'login') return false; // 等根导航切到已登录首页后再撤 Splash
final role = auth.user?.role ?? 'User';
if (role != 'User') return true; // 医生/管理员首页不依赖今日健康卡
final consent = ref.watch(aiConsentProvider);
if (consent.userId == auth.user?.id &&
!consent.isLoading &&
!consent.granted) {
return true;
}
if (currentRoute == 'aiConsentDetails' || currentRoute == 'staticText') {
return true;
}
if (!ref.watch(elderModeProvider).isLoaded) return false;
// 普通用户:等今日健康卡片数据(成功或失败都算就绪,避免卡死)
final health = ref.watch(latestHealthProvider);
return health.hasValue || health.hasError;
});
/// 启动闸门——就绪前在最上层覆盖 Splash盖住登录页闪现与首页对话流初始态。
/// 带 8 秒安全兜底,避免离线时数据永不返回导致卡在启动页。
class _BootGate extends ConsumerStatefulWidget {
final Widget child;
const _BootGate({required this.child});
@override
ConsumerState<_BootGate> createState() => _BootGateState();
}
class _BootGateState extends ConsumerState<_BootGate> {
bool _timedOut = false;
Timer? _timer;
@override
void initState() {
super.initState();
_startTimeout();
}
void _startTimeout() {
_timer?.cancel();
_timer = Timer(const Duration(seconds: 8), () {
if (mounted) setState(() => _timedOut = true);
});
}
void _handleReadyChanged(bool? previous, bool ready) {
if (ready) {
_timer?.cancel();
if (_timedOut) setState(() => _timedOut = false);
return;
}
if (previous != false) {
if (_timedOut) setState(() => _timedOut = false);
_startTimeout();
}
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
ref.listen<bool>(appReadyProvider, _handleReadyChanged);
final ready = ref.watch(appReadyProvider) || _timedOut;
return Stack(
children: [
widget.child,
if (!ready) const Positioned.fill(child: SplashPage()),
],
);
}
}
/// 根导航——根据 Riverpod 路由状态切换页面
class _RootNavigator extends ConsumerWidget {
const _RootNavigator();
@override
Widget build(BuildContext context, WidgetRef ref) {
final stack = ref.watch(routeStackProvider);
final current = stack.last;
final authState = ref.watch(authProvider);
final isPublicStaticText = current.name == 'staticText';
// 登录后自动跳转(在下一帧完成,无闪烁)
if (authState.isLoggedIn && current.name == 'login') {
WidgetsBinding.instance.addPostFrameCallback((_) {
ref.invalidate(latestHealthProvider);
ref.invalidate(medicationListProvider);
ref.invalidate(medicationReminderProvider);
ref.invalidate(currentExercisePlanProvider);
final role = authState.user?.role ?? 'User';
if (role == 'Admin') {
goRoute(ref, 'adminHome');
} else if (role == 'Doctor') {
goRoute(ref, 'doctorHome');
} else {
goRoute(ref, 'home');
}
});
}
if (!authState.isLoading &&
!authState.isLoggedIn &&
current.name != 'login' &&
!isPublicStaticText) {
WidgetsBinding.instance.addPostFrameCallback((_) {
goRoute(ref, 'login');
});
}
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, result) {
if (didPop) return;
if (stack.length > 1) {
popRoute(ref);
return;
}
final homeRoute = switch (authState.user?.role) {
'Admin' => 'adminHome',
'Doctor' => 'doctorHome',
_ => 'home',
};
if (current.name != homeRoute && authState.isLoggedIn) {
goRoute(ref, homeRoute);
}
},
child: Navigator(
pages: [
for (var index = 0; index < stack.length; index++)
MaterialPage<void>(
key: ValueKey(_pageKey(stack[index], index)),
name: stack[index].name,
child: buildPage(stack[index], ref),
),
],
onDidRemovePage: (page) {
final routes = ref.read(routeStackProvider);
if (routes.length <= 1) return;
final lastIndex = routes.length - 1;
final currentPageKey = ValueKey(_pageKey(routes.last, lastIndex));
if (page.key == currentPageKey) popRoute(ref);
},
),
);
}
String _pageKey(RouteInfo route, int index) {
final params = route.params.entries
.map((entry) => '${entry.key}=${entry.value}')
.join('&');
return '$index:${route.name}:$params';
}
}