Files
AI-Health/health_app/lib/app.dart
MingNian 6e5d3e64cd feat: 健康仪表盘配色调整 + 登录页品牌升级 + iOS 配置 + 隐私文案修订 + 文档清理
## UI 配色
- 健康仪表盘: 背景从蓝紫粉三色渐变改为 #4FACFE 纯色蓝, 白字清晰不抢眼
- app_colors / app_design_tokens / app_theme: 配色体系微调
- 多个 widget 和页面跟随配色更新(health_drawer/admin_drawer/doctor_drawer/enterprise_widgets 等)

## 登录页品牌升级
- 新增品牌素材: drawer_background_v2 / login_background_v2 / health_login_character_transparent
- login_page 重构, 接入新品牌视觉
- app.dart 启动流程调整

## iOS / Android 配置
- Info.plist: 权限描述调整
- AppIcon / LaunchImage: 资源更新
- AndroidManifest: 权限微调

## 隐私文案修订
- privacy.html: 蓝牙设备描述调整为"标准蓝牙血压服务", 移除未上线设备类型表述
- terms.html: 移除"在线医生咨询"相关条款, 服务范围收窄

## 通知中心
- notification_center_page: 重构通知项布局

## 其他
- 删除已完成的计划/spec 文档(ui-system-first-pass / apple-sign-in / secondary-page-color-refresh / notification-preferences-design / ui-design-system)
- 新增 native_navigation_test
- 新增 HANDOFF 交接文档
- home_message_order_test 更新
- .gitignore 调整
2026-07-16 22:30:23 +08:00

165 lines
5.4 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/navigation_provider.dart';
import 'pages/splash_page.dart';
import 'providers/auth_provider.dart';
import 'providers/data_providers.dart';
/// 健康管家 App 根组件
class HealthApp extends ConsumerWidget {
const HealthApp({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return MaterialApp(
title: '小脉健康',
debugShowCheckedModeBanner: false,
theme: 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) => ShadTheme(
data: AppTheme.shadTheme,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
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 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();
_timer = Timer(const Duration(seconds: 8), () {
if (mounted) setState(() => _timedOut = true);
});
}
@override
void dispose() {
_timer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
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: stack.length <= 1,
onPopInvokedWithResult: (didPop, result) {
if (!didPop) popRoute(ref);
},
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: (_) {
if (ref.read(routeStackProvider).length > 1) 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';
}
}