feat(client): iOS 上架合规——首启隐私同意弹窗 + 注销账号入口
个保法/审核 5.1.1:移动端首启全屏隐私同意(链接隐私政策/用户协议, 同意持久化;不同意 Android 退出、iOS 留提示);我的页 iOS 形态新增 「注销账号」(sheet 说明 + 预填 mailto 至 support@51yanmei.com)。 关于页隐私政策/服务条款链接已有,桌面/Web 零改动。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -12,9 +12,22 @@ import 'package:flutter/foundation.dart' show kIsWeb, visibleForTesting;
|
||||
@visibleForTesting
|
||||
bool? debugForceHideExternalPurchaseUi;
|
||||
|
||||
/// 为真时隐藏一切外部购买 UI(iOS App Store 包)。
|
||||
bool get hideExternalPurchaseUi =>
|
||||
/// 测试注入:模拟「需要首启隐私同意」的移动端形态。
|
||||
@visibleForTesting
|
||||
bool? debugForcePrivacyConsentRequired;
|
||||
|
||||
/// 是否 iOS App Store 分发包(注销账号入口等合规件按此渲染)。
|
||||
bool get isIosAppStoreBuild =>
|
||||
debugForceHideExternalPurchaseUi ?? (!kIsWeb && Platform.isIOS);
|
||||
|
||||
/// 为真时隐藏一切外部购买 UI(iOS App Store 包)。
|
||||
bool get hideExternalPurchaseUi => isIosAppStoreBuild;
|
||||
|
||||
/// 是否需要首启「个人信息保护」同意弹层(个保法 + 应用商店审核):
|
||||
/// 仅移动端(iOS/Android);桌面与 Web 不弹。
|
||||
bool get needsPrivacyConsent =>
|
||||
debugForcePrivacyConsentRequired ??
|
||||
(!kIsWeb && (Platform.isIOS || Platform.isAndroid));
|
||||
|
||||
/// 客服邮箱(授权管理「联系客服」卡、异常兜底提示等处使用)。
|
||||
const String supportEmail = 'support@51yanmei.com';
|
||||
|
||||
@@ -14,6 +14,7 @@ import 'core/theme/theme_controller.dart';
|
||||
import 'core/theme/themes.dart';
|
||||
import 'providers/connectivity_provider.dart';
|
||||
import 'widgets/ds/ds_atoms.dart';
|
||||
import 'widgets/privacy_consent_gate.dart';
|
||||
|
||||
void main() {
|
||||
usePathUrlStrategy();
|
||||
@@ -113,6 +114,9 @@ class _JiuAppState extends ConsumerState<JiuApp> {
|
||||
title: kDebugMode ? '岩美 [DEBUG]' : '岩美',
|
||||
theme: buildTheme(themeKey),
|
||||
routerConfig: router,
|
||||
// 首启隐私同意闸(移动端 only):覆盖全部路由,同意前遮罩交互
|
||||
builder: (context, child) =>
|
||||
PrivacyConsentGate(child: child ?? const SizedBox.shrink()),
|
||||
debugShowCheckedModeBanner: false,
|
||||
localizationsDelegates: const [
|
||||
GlobalMaterialLocalizations.delegate,
|
||||
|
||||
@@ -7,8 +7,10 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:lucide_icons_flutter/lucide_icons.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../../core/auth/auth_state.dart';
|
||||
import '../../core/config/store_compliance.dart';
|
||||
import '../../core/theme/app_dims.g.dart';
|
||||
import '../../core/theme/context_tokens.dart';
|
||||
import '../../providers/shop_provider.dart';
|
||||
@@ -86,6 +88,12 @@ class MeScreen extends ConsumerWidget {
|
||||
icon: LucideIcons.info,
|
||||
label: '关于我们',
|
||||
onTap: () => context.go('/about')),
|
||||
// iOS App Store 审核 5.1.1(v):应用内须可发起账号删除
|
||||
if (isIosAppStoreBuild)
|
||||
MHubItem(
|
||||
icon: LucideIcons.userX,
|
||||
label: '注销账号',
|
||||
onTap: () => _showDeleteAccountSheet(context, user)),
|
||||
]),
|
||||
const SizedBox(height: 18),
|
||||
_LogoutButton(onConfirm: () {
|
||||
@@ -151,6 +159,65 @@ class MeScreen extends ConsumerWidget {
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// 注销账号(iOS App Store 审核 5.1.1(v)):应用内发起 → 邮件人工核验。
|
||||
/// 措辞保持中性,不涉及任何购买/续费引导。
|
||||
void _showDeleteAccountSheet(BuildContext context, AuthUser? user) {
|
||||
showMSheet<void>(
|
||||
context,
|
||||
title: '注销账号',
|
||||
builder: (ctx) {
|
||||
final t = ctx.tokens;
|
||||
Widget note(String s) => Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text('· $s',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.muted, height: 1.6)),
|
||||
);
|
||||
final who = user != null ? '${user.shopNo} · ${user.username}' : '';
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 4, 16, 20),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('注销将删除您的登录账号,操作不可恢复。',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsBody,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: t.heading)),
|
||||
const SizedBox(height: 8),
|
||||
note('账号与登录凭据将被删除,无法找回'),
|
||||
note('门店经营数据归属门店:门店内仍有其他用户时数据保留,'
|
||||
'需整店删除请在申请中说明'),
|
||||
note('为防止误操作与冒用,注销需人工核验身份,'
|
||||
'我们将在收到申请后 15 个工作日内完成处理'),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: DsButton('发送注销申请邮件',
|
||||
variant: DsBtnVariant.primary,
|
||||
onPressed: () {
|
||||
final subject = Uri.encodeComponent('账号注销申请($who)');
|
||||
final body = Uri.encodeComponent(
|
||||
'申请注销账号:$who\n注销原因(选填):\n\n'
|
||||
'请使用注册时预留的联系方式发送本邮件以便核验。');
|
||||
launchUrl(Uri.parse(
|
||||
'mailto:$supportEmail?subject=$subject&body=$body'));
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Center(
|
||||
child: Text('客服邮箱 $supportEmail',
|
||||
style:
|
||||
TextStyle(fontSize: AppDims.fsXs, color: t.faint)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogoutButton extends StatelessWidget {
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
// 首启个人信息保护同意闸(个保法 + App Store 中国区审核 5.1.1)。
|
||||
//
|
||||
// 移动端(iOS/Android)首次启动时全屏遮罩弹出:说明收集范围 + 可点
|
||||
// 《隐私政策》《用户协议》+ 同意/不同意。同意持久化(SharedPreferences)
|
||||
// 后不再出现;不同意时 Android 退出应用,iOS 留在弹层并提示(苹果 HIG
|
||||
// 禁止程序性退出)。桌面与 Web 完全不弹。
|
||||
// 挂载点:MaterialApp.router 的 builder(覆盖全部路由,早于登录页交互)。
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'dart:io' show Platform;
|
||||
|
||||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
import '../core/config/app_info.dart';
|
||||
import '../core/config/store_compliance.dart';
|
||||
import '../core/theme/app_dims.g.dart';
|
||||
import '../core/theme/context_tokens.dart';
|
||||
import 'ds/ds_atoms.dart';
|
||||
|
||||
const _kConsentKey = 'privacy_consent_v1';
|
||||
|
||||
class PrivacyConsentGate extends StatefulWidget {
|
||||
final Widget child;
|
||||
const PrivacyConsentGate({super.key, required this.child});
|
||||
|
||||
@override
|
||||
State<PrivacyConsentGate> createState() => _PrivacyConsentGateState();
|
||||
}
|
||||
|
||||
class _PrivacyConsentGateState extends State<PrivacyConsentGate> {
|
||||
bool _needConsent = false;
|
||||
bool _declinedHint = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
if (needsPrivacyConsent) _check();
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
if (mounted && !(prefs.getBool(_kConsentKey) ?? false)) {
|
||||
setState(() => _needConsent = true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _agree() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setBool(_kConsentKey, true);
|
||||
if (mounted) setState(() => _needConsent = false);
|
||||
}
|
||||
|
||||
void _decline() {
|
||||
// Android 惯例:不同意即退出;iOS 禁止程序性退出,留提示。
|
||||
if (!kIsWeb && Platform.isAndroid) {
|
||||
SystemNavigator.pop();
|
||||
} else {
|
||||
setState(() => _declinedHint = true);
|
||||
}
|
||||
}
|
||||
|
||||
void _open(String path) {
|
||||
final base = AppInfo.website.isNotEmpty
|
||||
? AppInfo.website
|
||||
: 'https://jiu.51yanmei.com';
|
||||
launchUrl(Uri.parse('$base$path'), mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!_needConsent) return widget.child;
|
||||
final t = context.tokens;
|
||||
final link = TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.primary, fontWeight: FontWeight.w600);
|
||||
return Stack(children: [
|
||||
widget.child,
|
||||
const ModalBarrier(
|
||||
dismissible: false, color: Colors.black54), // ds-ignore 遮罩固定色
|
||||
Center(
|
||||
child: Material(
|
||||
color: Colors.transparent,
|
||||
child: Container(
|
||||
width: 320,
|
||||
margin: const EdgeInsets.symmetric(horizontal: 24),
|
||||
padding: const EdgeInsets.fromLTRB(22, 22, 22, 18),
|
||||
decoration: BoxDecoration(
|
||||
color: t.surface,
|
||||
borderRadius: BorderRadius.circular(AppDims.rLg),
|
||||
border: Border.all(color: t.border),
|
||||
),
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
Text('个人信息保护提示',
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsH2,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: t.heading)),
|
||||
const SizedBox(height: 12),
|
||||
Text.rich(
|
||||
TextSpan(
|
||||
style: TextStyle(
|
||||
fontSize: AppDims.fsSm, color: t.text, height: 1.7),
|
||||
children: [
|
||||
const TextSpan(
|
||||
text: '感谢使用岩美酒库。为向您提供门店库存管理服务,'
|
||||
'我们仅收集必要的账号信息、设备信息与操作日志,'
|
||||
'用于多端数据同步与账号安全保障。详情请阅读'),
|
||||
TextSpan(
|
||||
text: '《隐私政策》',
|
||||
style: link,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => _open('/privacy/')),
|
||||
const TextSpan(text: '与'),
|
||||
TextSpan(
|
||||
text: '《用户协议》',
|
||||
style: link,
|
||||
recognizer: TapGestureRecognizer()
|
||||
..onTap = () => _open('/terms/')),
|
||||
const TextSpan(text: '。点击「同意并继续」即表示您已阅读并同意上述条款。'),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_declinedHint) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text('需同意后方可使用岩美酒库',
|
||||
style: TextStyle(fontSize: AppDims.fsXs, color: t.warn)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Row(children: [
|
||||
Expanded(
|
||||
child: DsButton('不同意', onPressed: _decline)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: DsButton('同意并继续',
|
||||
variant: DsBtnVariant.primary, onPressed: _agree)),
|
||||
]),
|
||||
]),
|
||||
),
|
||||
),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:jiu_client/core/config/store_compliance.dart';
|
||||
import 'package:jiu_client/core/theme/themes.dart';
|
||||
import 'package:jiu_client/widgets/privacy_consent_gate.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// 首启隐私同意闸:移动端首启弹出并阻断交互,同意持久化后不再出现;
|
||||
/// 不同意(非 Android)留在弹层并给提示。
|
||||
|
||||
Widget _app() => MaterialApp(
|
||||
theme: buildTheme('a'),
|
||||
builder: (c, child) =>
|
||||
PrivacyConsentGate(child: child ?? const SizedBox.shrink()),
|
||||
home: const Scaffold(body: Text('APP')),
|
||||
);
|
||||
|
||||
void main() {
|
||||
tearDown(() => debugForcePrivacyConsentRequired = null);
|
||||
|
||||
testWidgets('首启弹出,同意后关闭并持久化', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
debugForcePrivacyConsentRequired = true;
|
||||
|
||||
await tester.pumpWidget(_app());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('个人信息保护提示'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.text('同意并继续'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('个人信息保护提示'), findsNothing);
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
expect(prefs.getBool('privacy_consent_v1'), isTrue);
|
||||
});
|
||||
|
||||
testWidgets('已同意过:不再弹出', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({'privacy_consent_v1': true});
|
||||
debugForcePrivacyConsentRequired = true;
|
||||
|
||||
await tester.pumpWidget(_app());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('个人信息保护提示'), findsNothing);
|
||||
expect(find.text('APP'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('不同意(非 Android):留在弹层并提示', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
debugForcePrivacyConsentRequired = true;
|
||||
|
||||
await tester.pumpWidget(_app());
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('不同意'));
|
||||
await tester.pump();
|
||||
expect(find.text('需同意后方可使用岩美酒库'), findsOneWidget);
|
||||
expect(find.text('个人信息保护提示'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('桌面/Web 形态:不弹', (tester) async {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
debugForcePrivacyConsentRequired = false;
|
||||
|
||||
await tester.pumpWidget(_app());
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('个人信息保护提示'), findsNothing);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user