diff --git a/client/lib/core/config/store_compliance.dart b/client/lib/core/config/store_compliance.dart index c3cc855..b7a5b7b 100644 --- a/client/lib/core/config/store_compliance.dart +++ b/client/lib/core/config/store_compliance.dart @@ -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'; diff --git a/client/lib/main.dart b/client/lib/main.dart index 66880eb..6151bfc 100644 --- a/client/lib/main.dart +++ b/client/lib/main.dart @@ -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 { title: kDebugMode ? '岩美 [DEBUG]' : '岩美', theme: buildTheme(themeKey), routerConfig: router, + // 首启隐私同意闸(移动端 only):覆盖全部路由,同意前遮罩交互 + builder: (context, child) => + PrivacyConsentGate(child: child ?? const SizedBox.shrink()), debugShowCheckedModeBanner: false, localizationsDelegates: const [ GlobalMaterialLocalizations.delegate, diff --git a/client/lib/screens/me/me_screen.dart b/client/lib/screens/me/me_screen.dart index a219a8a..aea08c6 100644 --- a/client/lib/screens/me/me_screen.dart +++ b/client/lib/screens/me/me_screen.dart @@ -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( + 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 { diff --git a/client/lib/widgets/privacy_consent_gate.dart b/client/lib/widgets/privacy_consent_gate.dart new file mode 100644 index 0000000..8b4ab73 --- /dev/null +++ b/client/lib/widgets/privacy_consent_gate.dart @@ -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 createState() => _PrivacyConsentGateState(); +} + +class _PrivacyConsentGateState extends State { + bool _needConsent = false; + bool _declinedHint = false; + + @override + void initState() { + super.initState(); + if (needsPrivacyConsent) _check(); + } + + Future _check() async { + final prefs = await SharedPreferences.getInstance(); + if (mounted && !(prefs.getBool(_kConsentKey) ?? false)) { + setState(() => _needConsent = true); + } + } + + Future _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)), + ]), + ]), + ), + ), + ), + ]); + } +} diff --git a/client/test/privacy_consent_gate_test.dart b/client/test/privacy_consent_gate_test.dart new file mode 100644 index 0000000..38d8145 --- /dev/null +++ b/client/test/privacy_consent_gate_test.dart @@ -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); + }); +}