feat: align iOS client safety and consent experience

This commit is contained in:
MingNian
2026-07-29 13:30:34 +08:00
parent 3b5cec10a6
commit 01f4955e0e
11 changed files with 290 additions and 374 deletions

View File

@@ -8,9 +8,11 @@ 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 {
@@ -51,7 +53,7 @@ class HealthApp extends ConsumerWidget {
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () => FocusManager.instance.primaryFocus?.unfocus(),
child: _BootGate(child: child!),
child: AiConsentGate(child: _BootGate(child: child!)),
),
),
),
@@ -71,6 +73,15 @@ final appReadyProvider = Provider<bool>((ref) {
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);

View File

@@ -9,9 +9,22 @@ const String baseUrl = String.fromEnvironment(
'API_BASE_URL',
defaultValue: kReleaseMode
? 'https://erpapi.datalumina.cn/xiaomai'
: 'http://192.168.1.34:5000',
: 'http://10.4.232.91:5000',
);
const String _configuredAppPolicy = String.fromEnvironment(
'APP_POLICY_PROFILE',
defaultValue: '',
);
String get appPolicyProfile {
if (_configuredAppPolicy == 'android-standard' ||
_configuredAppPolicy == 'ios-medical-citations') {
return _configuredAppPolicy;
}
return Platform.isIOS ? 'ios-medical-citations' : 'android-standard';
}
class ApiException implements Exception {
final int? code;
final String message;
@@ -56,7 +69,10 @@ class ApiClient {
baseUrl: baseUrl,
connectTimeout: const Duration(seconds: 15),
receiveTimeout: const Duration(seconds: 60),
headers: {'Content-Type': 'application/json'},
headers: {
'Content-Type': 'application/json',
'X-App-Policy': appPolicyProfile,
},
),
) {
_dio.interceptors.add(_AuthInterceptor(this));

View File

@@ -438,9 +438,9 @@ class _HomePageState extends ConsumerState<HomePage>
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 8),
const SizedBox(height: 4),
_buildAgentBar(),
const SizedBox(height: 12),
const SizedBox(height: 5),
if (_pickedImagePath != null) _buildImagePreview(),
_buildInputBar(),
],
@@ -490,13 +490,13 @@ class _HomePageState extends ConsumerState<HomePage>
chatProvider.select((state) => state.isStreaming),
);
return Padding(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 10),
padding: const EdgeInsets.fromLTRB(12, 0, 12, 6),
child: Container(
padding: EdgeInsets.fromLTRB(
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
6,
elderMode ? 7 : 5,
elderMode ? 5 : 3,
),
decoration: BoxDecoration(
color: Colors.white,
@@ -540,7 +540,7 @@ class _HomePageState extends ConsumerState<HomePage>
isDense: true,
contentPadding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 15 : 12,
vertical: elderMode ? 12 : 9,
),
border: InputBorder.none,
enabledBorder: InputBorder.none,
@@ -971,7 +971,7 @@ class _VoiceHoldSurface extends StatelessWidget {
return Container(
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: elderMode ? 14 : 11,
vertical: elderMode ? 12 : 9,
),
alignment: Alignment.center,
child: Text(

View File

@@ -1276,7 +1276,6 @@ class ChatMessagesView extends ConsumerWidget {
/// 处理 AI 回复里的 markdown 链接点击:
/// - app://diet → 触发拍照/相册选择,跳到饮食拍照流程
/// - app://report → 跳到报告列表(用户可在那里上传新报告)
/// - app://device → 跳到蓝牙设备页
/// - 其他 app://xxx → 当作 route name 直接跳
static void _handleMarkdownLink(
BuildContext context,
@@ -1296,8 +1295,7 @@ class ChatMessagesView extends ConsumerWidget {
pushRoute(ref, 'reports');
break;
case 'device':
pushRoute(ref, 'devices');
break;
return;
default:
if (uri.host.isNotEmpty) pushRoute(ref, uri.host);
}
@@ -2199,12 +2197,6 @@ final _agentActions = <ActiveAgent, List<_AgentAction>>{
isWide: true,
route: 'trend',
),
_AgentAction(
label: '蓝牙录入',
icon: Icons.bluetooth,
isWide: true,
route: 'devices',
),
],
ActiveAgent.diet: [
_AgentAction(

View File

@@ -32,7 +32,15 @@ class AiConsentDetailsPage extends ConsumerWidget {
);
if (confirmed != true || !context.mounted) return;
await ref.read(aiConsentProvider.notifier).revoke(userId);
final revoked = await ref.read(aiConsentProvider.notifier).revoke(userId);
if (!revoked) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Authorization could not be revoked.')),
);
}
return;
}
await ref.read(authProvider.notifier).logout();
}
@@ -59,6 +67,7 @@ class AiConsentDetailsPage extends ConsumerWidget {
color: AppColors.textPrimary,
),
),
centerTitle: true,
),
body: SafeArea(
child: SingleChildScrollView(
@@ -91,8 +100,11 @@ class AiConsentDetailsPage extends ConsumerWidget {
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: () => _revoke(context, ref),
onPressed: consent.isSaving
? null
: () => _revoke(context, ref),
style: OutlinedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: AppColors.error,
side: BorderSide(
color: AppColors.error.withValues(alpha: 0.4),

View File

@@ -1,4 +1,3 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -56,8 +55,6 @@ class SettingsPage extends ConsumerWidget {
title: '长辈模式',
onTap: () => pushRoute(ref, 'elderMode'),
),
if (!Platform.isIOS)
// iOS 审核版:蓝牙设备入口已移除
_SettingsTile(
icon: LucideIcons.bell,
title: '消息通知',

View File

@@ -6,22 +6,18 @@ const aiConsentVersion = 'ai-data-consent-v1';
class AiConsentState {
final bool isLoading;
final bool isSaving;
final bool granted;
final String? userId;
final String? error;
const AiConsentState({
this.isLoading = true,
this.isSaving = false,
this.granted = false,
this.userId,
this.error,
});
AiConsentState copyWith({bool? isLoading, bool? granted, String? userId}) {
return AiConsentState(
isLoading: isLoading ?? this.isLoading,
granted: granted ?? this.granted,
userId: userId ?? this.userId,
);
}
}
final aiConsentProvider = NotifierProvider<AiConsentNotifier, AiConsentState>(
@@ -36,21 +32,62 @@ class AiConsentNotifier extends Notifier<AiConsentState> {
Future<void> load(String userId) async {
state = AiConsentState(isLoading: true, userId: userId);
try {
final value = await ref.read(localDbProvider).read(_key(userId));
state = AiConsentState(
isLoading: false,
granted: value == 'granted',
userId: userId,
);
} catch (_) {
state = AiConsentState(
isLoading: false,
userId: userId,
error: 'Authorization status could not be loaded.',
);
}
}
Future<void> grant(String userId) async {
Future<bool> grant(String userId) async {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).write(_key(userId), 'granted');
state = AiConsentState(isLoading: false, granted: true, userId: userId);
return true;
} catch (_) {
state = AiConsentState(
isLoading: false,
userId: userId,
error: 'Authorization could not be saved.',
);
return false;
}
}
Future<void> revoke(String userId) async {
Future<bool> revoke(String userId) async {
state = AiConsentState(
isLoading: false,
isSaving: true,
granted: state.granted,
userId: userId,
);
try {
await ref.read(localDbProvider).delete(_key(userId));
state = AiConsentState(isLoading: false, granted: false, userId: userId);
return true;
} catch (_) {
state = AiConsentState(
isLoading: false,
granted: true,
userId: userId,
error: 'Authorization could not be revoked.',
);
return false;
}
}
}

View File

@@ -63,7 +63,10 @@ class SseHandler {
cancelToken: cancelToken,
options: Options(
responseType: ResponseType.stream,
headers: {'Authorization': 'Bearer $token'},
headers: {
'Authorization': 'Bearer $token',
'X-App-Policy': appPolicyProfile,
},
),
);

View File

@@ -3,6 +3,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/navigation_provider.dart';
import '../providers/ai_consent_provider.dart';
import '../providers/auth_provider.dart';
import '../pages/settings/ai_consent_details_page.dart';
@@ -21,7 +22,9 @@ class _AiConsentGateState extends ConsumerState<AiConsentGate> {
@override
Widget build(BuildContext context) {
final user = ref.watch(authProvider).user;
if (user == null || user.id.isEmpty) return widget.child;
if (user == null || user.id.isEmpty || user.role != 'User') {
return widget.child;
}
if (_loadedUserId != user.id) {
_loadedUserId = user.id;
@@ -31,19 +34,33 @@ class _AiConsentGateState extends ConsumerState<AiConsentGate> {
}
final consent = ref.watch(aiConsentProvider);
final currentRoute = ref.watch(currentRouteProvider).name;
final isConsentInformationRoute =
currentRoute == 'aiConsentDetails' || currentRoute == 'staticText';
if (consent.userId != user.id || consent.isLoading) {
return const Scaffold(backgroundColor: Colors.white);
return const Scaffold(
body: Center(
child: CircularProgressIndicator(color: AppColors.primary),
),
);
}
if (consent.granted) return widget.child;
if (consent.granted || isConsentInformationRoute) return widget.child;
return Scaffold(
backgroundColor: AppColors.background,
body: Center(
child: Padding(
padding: const EdgeInsets.all(24),
body: DecoratedBox(
decoration: const BoxDecoration(gradient: AppColors.bgGradient),
child: SafeArea(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 20),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 400),
child: _ConsentCard(userId: user.id),
),
),
),
),
),
);
}
}
@@ -53,7 +70,12 @@ class _ConsentCard extends ConsumerWidget {
const _ConsentCard({required this.userId});
Future<void> _grant(BuildContext context, WidgetRef ref) async {
await ref.read(aiConsentProvider.notifier).grant(userId);
final succeeded = await ref.read(aiConsentProvider.notifier).grant(userId);
if (!succeeded && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Authorization could not be saved.')),
);
}
}
Future<void> _decline(BuildContext context, WidgetRef ref) async {
@@ -62,17 +84,55 @@ class _ConsentCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(borderRadius: AppRadius.xlBorder),
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 24, 22, 20),
final consent = ref.watch(aiConsentProvider);
return Container(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96),
borderRadius: AppRadius.cardBorder,
border: Border.all(color: AppColors.primaryLight, width: 1),
boxShadow: AppShadows.panel,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Align(
child: Image.asset(
'assets/branding/health_login_character_transparent.png',
height: 112,
fit: BoxFit.contain,
),
),
const SizedBox(height: 2),
Align(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: AppColors.primarySoft,
borderRadius: AppRadius.pillBorder,
),
child: const Text(
'AI Service',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: AppColors.primaryDark,
),
),
),
),
const SizedBox(height: 14),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Icon(Icons.auto_awesome, size: 40, color: AppColors.primary),
const Icon(
Icons.auto_awesome,
size: 40,
color: AppColors.primary,
),
const SizedBox(height: 14),
const Text(
'AI 健康服务授权',
@@ -110,17 +170,23 @@ class _ConsentCard extends ConsumerWidget {
),
const SizedBox(height: 8),
FilledButton(
onPressed: () => _grant(context, ref),
onPressed: consent.isSaving
? null
: () => _grant(context, ref),
child: const Text('同意并继续'),
),
const SizedBox(height: 8),
OutlinedButton(
onPressed: () => _decline(context, ref),
onPressed: consent.isSaving
? null
: () => _decline(context, ref),
child: const Text('不同意并退出'),
),
],
),
),
],
),
);
}
}

View File

@@ -1,149 +0,0 @@
import 'package:flutter/material.dart';
import '../core/app_colors.dart';
import '../core/app_design_tokens.dart';
import '../core/app_module_visuals.dart';
import '../models/bp_reading.dart';
Future<void> showBleSyncDialog(
BuildContext context, {
required BpReading reading,
}) {
return showDialog<void>(
context: context,
barrierDismissible: false,
barrierColor: Colors.black.withValues(alpha: 0.54),
builder: (ctx) => Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 28),
backgroundColor: Colors.transparent,
child: Container(
width: double.infinity,
constraints: const BoxConstraints(maxWidth: 380),
padding: const EdgeInsets.fromLTRB(22, 26, 22, 22),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: AppRadius.xlBorder,
boxShadow: AppShadows.floating,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: AppModuleVisuals.device.color,
borderRadius: AppRadius.mdBorder,
),
child: Icon(
AppModuleVisuals.device.icon,
color: Colors.white,
size: 25,
),
),
const SizedBox(height: 14),
Text(
'数据已录入',
textAlign: TextAlign.center,
style: AppTextStyles.summaryTitle.copyWith(fontSize: 22),
),
const SizedBox(height: 22),
Row(
children: [
Expanded(
child: _MetricCard(
label: '收缩压',
value: '${reading.systolic}',
unit: 'mmHg',
),
),
const SizedBox(width: 14),
Expanded(
child: _MetricCard(
label: '舒张压',
value: '${reading.diastolic}',
unit: 'mmHg',
),
),
],
),
if (reading.pulse != null) ...[
const SizedBox(height: 14),
Row(
children: [
Expanded(
child: _MetricCard(
label: '脉搏',
value: '${reading.pulse}',
unit: 'bpm',
),
),
const Expanded(child: SizedBox.shrink()),
],
),
],
const SizedBox(height: 26),
GestureDetector(
onTap: () => Navigator.pop(ctx),
child: Container(
height: 50,
width: double.infinity,
alignment: Alignment.center,
decoration: BoxDecoration(
gradient: AppModuleVisuals.device.gradient,
borderRadius: AppRadius.lgBorder,
boxShadow: AppColors.buttonShadow,
),
child: Text(
'知道了',
style: AppTextStyles.button.copyWith(color: Colors.white),
),
),
),
],
),
),
),
);
}
class _MetricCard extends StatelessWidget {
final String label;
final String value;
final String unit;
const _MetricCard({
required this.label,
required this.value,
required this.unit,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(vertical: 16),
decoration: BoxDecoration(
color: AppModuleVisuals.device.lightColor,
borderRadius: AppRadius.mdBorder,
border: Border.all(color: AppModuleVisuals.device.borderColor),
),
child: Column(
children: [
Text(
value,
style: const TextStyle(
fontSize: 36,
fontWeight: FontWeight.w700,
color: AppColors.textPrimary,
height: 1,
),
),
const SizedBox(height: 6),
Text(unit, style: AppTextStyles.tag),
const SizedBox(height: 4),
Text(label, style: AppTextStyles.tag),
],
),
);
}
}

View File

@@ -1,5 +1,3 @@
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:shadcn_ui/shadcn_ui.dart';
@@ -328,40 +326,16 @@ class _MetricTile extends StatelessWidget {
@override
Widget build(BuildContext context) {
final elderMode = ElderModeScope.enabledOf(context);
final height = elderMode ? 112.0 : 100.0;
return ClipRRect(
borderRadius: BorderRadius.circular(18),
child: BackdropFilter(
filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10),
child: Material(
color: Colors.transparent,
child: InkWell(
return InkWell(
onTap: onTap,
child: Container(
height: height,
padding: const EdgeInsets.symmetric(horizontal: 2, vertical: 7),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
Colors.white.withValues(alpha: 0.62),
Colors.white.withValues(alpha: 0.42),
const Color(0xFFD9FCFF).withValues(alpha: 0.34),
],
),
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.82),
width: 1.2,
),
boxShadow: [
BoxShadow(
color: const Color(0xFF0369A1).withValues(alpha: 0.10),
blurRadius: 10,
offset: const Offset(0, 4),
),
],
child: Container(
height: elderMode ? 108 : 100,
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 10),
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.40)),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -375,8 +349,8 @@ class _MetricTile extends StatelessWidget {
maxLines: 1,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 23,
fontWeight: FontWeight.w800,
fontSize: 26,
fontWeight: FontWeight.w700,
color: Colors.white,
),
),
@@ -386,9 +360,9 @@ class _MetricTile extends StatelessWidget {
Text(
info.unit!,
style: const TextStyle(
fontSize: 13,
fontSize: 12,
fontWeight: FontWeight.w600,
color: Color(0xE8FFFFFF),
color: Color(0xE0FFFFFF),
),
),
const SizedBox(height: 2),
@@ -396,16 +370,13 @@ class _MetricTile extends StatelessWidget {
info.label,
style: const TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
],
),
),
),
),
),
);
}
}
@@ -460,12 +431,6 @@ class _NavigationSection extends StatelessWidget {
route: 'exercisePlan',
colors: AppColors.exerciseGradient.colors,
),
_NavItem(
icon: LucideIcons.bluetooth,
title: '设备',
route: 'devices',
colors: AppColors.deviceGradient.colors,
),
];
return _LightSection(
@@ -742,43 +707,9 @@ class _HealthDashboardBackgroundPainter extends CustomPainter {
..shader = const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [Color(0xFF4FACFE), Color(0xFF00F2FE)],
colors: [Color(0xFF00C6FB), Color(0xFF005BEA)],
).createShader(rect);
canvas.drawRect(rect, paint);
final glow = Paint()
..shader =
RadialGradient(
colors: [
Colors.white.withValues(alpha: 0.32),
Colors.white.withValues(alpha: 0),
],
).createShader(
Rect.fromCircle(
center: Offset(size.width * 0.12, size.height * 0.05),
radius: size.width * 0.7,
),
);
canvas.drawCircle(
Offset(size.width * 0.12, size.height * 0.05),
size.width * 0.7,
glow,
);
final highlight = Paint()
..color = Colors.white.withValues(alpha: 0.20)
..style = PaintingStyle.stroke
..strokeWidth = 1.2;
canvas.drawLine(
Offset(size.width * 0.52, -12),
Offset(size.width * 0.16, size.height + 18),
highlight,
);
canvas.drawLine(
Offset(size.width * 0.96, -10),
Offset(size.width * 0.58, size.height + 22),
highlight,
);
}
@override