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:
wangjia
2026-07-05 08:56:26 +08:00
parent dddf402d92
commit b740cc6edf
5 changed files with 298 additions and 2 deletions
+15 -2
View File
@@ -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);
/// 为真时隐藏一切外部购买 UIiOS App Store 包)。
bool get hideExternalPurchaseUi => isIosAppStoreBuild;
/// 是否需要首启「个人信息保护」同意弹层(个保法 + 应用商店审核):
/// 仅移动端(iOS/Android);桌面与 Web 不弹。
bool get needsPrivacyConsent =>
debugForcePrivacyConsentRequired ??
(!kIsWeb && (Platform.isIOS || Platform.isAndroid));
/// 客服邮箱(授权管理「联系客服」卡、异常兜底提示等处使用)。
const String supportEmail = 'support@51yanmei.com';
+4
View File
@@ -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,
+67
View File
@@ -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);
});
}