import 'package:flutter/material.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import '../../core/theme/app_palette.dart'; import '../../core/utils/logger.dart'; /// 「后台自动连接设备」的用户授权。 /// /// 为什么需要这个:连接支架/耳机时,原生侧会向系统注册 /// `BluetoothLeScanner.startScan(filters, settings, pendingIntent)` —— /// 也就是「以后系统扫到这台设备就把 App 唤醒」。App 被唤醒后 /// [BleWakeReceiver] 会拉起前台服务接管连接与录音。 /// /// 这在应用商店的合规检测里属于**自启动 / 关联启动**行为(小米检测项 /// 「APP频繁自启动和关联启动」),必须先向用户明示用途并取得同意才能注册。 /// 所以三条蓝牙链路在注册后台扫连之前,都要先过这里。 /// /// 用户没同意时:App 依然可以在前台手动连接使用,只是设备开机/靠近时 /// 不会自动唤醒 App 回连。用户可以在「设置」里随时开关。 class BackgroundLaunchConsent { BackgroundLaunchConsent._(); static const String _key = 'background_launch_consent'; static const String _timeKey = 'background_launch_consent_time'; static const String _tag = 'BgLaunchConsent'; static final GetStorage _storage = GetStorage(); /// 用户是否已同意后台自动连接(未表态视为不同意) static bool get granted => _storage.read(_key) ?? false; /// 是否已经问过(用于区分「拒绝过」和「还没问」) static bool get asked => _storage.hasData(_key); /// 同意时间戳,合规留痕用 static int get grantedAt => _storage.read(_timeKey) ?? 0; static Future setGranted(bool value) async { await _storage.write(_key, value); if (value) { await _storage.write(_timeKey, DateTime.now().millisecondsSinceEpoch); } Logger.i(_tag, '后台自动连接授权: $value'); } /// 用户明确拒绝过(弹过窗且点了「暂不开启」) static bool get declined => asked && !granted; /// 取得授权。 /// /// [userInitiated] 为 true 时才允许弹窗——也就是只有用户**亲手点了连接** /// 才会看到明示弹窗。自动路径(进页面自动连、冷启动回连)一律只读取已有 /// 授权状态,绝不弹窗。 /// /// 拒绝之后的行为(应用商店合规场景 1/2 + 不骚扰用户): /// - [declined] 是落盘状态(GetStorage),不是内存标记,App 重启后依然生效; /// - 只要 [declined] 为 true 就永久不再自动弹这个授权窗——不止「同一次使用」, /// 而是**此后任何一次启动、任何一次点连接**都不再弹,只静默返回 false; /// - 拒绝当下([_showDialog] 里点「暂不开启」)会紧接着在同一次交互里弹一条 /// 「已关闭,可去设置重新开启」的一次性提示,把「问一次 + 告知后果」在 /// 一次操作里问完,之后彻底不再有任何弹窗; /// - 想重新开启,只能用户自己去「设置 → 设备自动连接」打开, /// 那条路径走 [requestFromSettings]。 static Future ensure({bool userInitiated = false}) async { if (granted) return true; if (!userInitiated) return false; if (declined) return false; final ok = await _showDialog(); await setGranted(ok); if (!ok) await _showDeclinedNotice(); return ok; } /// 设置页里用户主动打开开关:这是明确的开启意图,允许再次弹明示窗。 /// /// 这里不追加 [_showDeclinedNotice]——用户本来就站在设置页,开关本身 /// 就是"还能再开"的可见状态,不用再告诉他去设置页。 static Future requestFromSettings() async { if (granted) return true; final ok = await _showDialog(); await setGranted(ok); return ok; } /// 拒绝后紧接着弹的一次性告知——不是再问一次,是告诉用户「已经关了、 /// 去哪开」。与上面的授权询问算同一次交互,不算合规意义上的「二次索要」。 /// 只在 [ensure] 的自动流程(非设置页)里追加,理由见上。 static Future _showDeclinedNotice() async { final bool isDark = Get.isDarkMode; await Get.dialog( AlertDialog( backgroundColor: AppPalette.surface(isDark), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), content: Text( 'autoConnectReenableHint'.tr, style: TextStyle( fontSize: 13.sp, height: 1.6, color: AppPalette.textSecondary(isDark), ), ), actions: [ TextButton( onPressed: () => Get.back(), child: Text('gotIt'.tr, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: AppPalette.accent)), ), ], ), barrierDismissible: false, ); } static Future _showDialog() async { final bool isDark = Get.isDarkMode; final result = await Get.dialog( AlertDialog( backgroundColor: AppPalette.surface(isDark), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16.r)), title: Text( 'autoConnectConsentTitle'.tr, style: TextStyle( fontSize: 17.sp, fontWeight: FontWeight.w600, color: AppPalette.textPrimary(isDark), ), ), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( 'autoConnectConsentIntro'.tr, style: TextStyle( fontSize: 13.sp, height: 1.6, color: AppPalette.textSecondary(isDark), ), ), SizedBox(height: 10.h), _bullet('autoConnectConsentB1'.tr, isDark), _bullet('autoConnectConsentB2'.tr, isDark), _bullet('autoConnectConsentB3'.tr, isDark), _bullet('autoConnectConsentB4'.tr, isDark), ], ), actions: [ TextButton( onPressed: () => Get.back(result: false), child: Text('autoConnectLater'.tr, style: TextStyle( fontSize: 14.sp, color: AppPalette.textHint(isDark))), ), TextButton( onPressed: () => Get.back(result: true), child: Text('autoConnectAgree'.tr, style: TextStyle( fontSize: 14.sp, fontWeight: FontWeight.w600, color: AppPalette.accent)), ), ], ), barrierDismissible: false, ); return result ?? false; } static Widget _bullet(String text, bool isDark) { return Padding( padding: EdgeInsets.only(bottom: 6.h), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: EdgeInsets.only(top: 6.h, right: 6.w), child: Container( width: 4.w, height: 4.w, decoration: BoxDecoration( color: AppPalette.accent, shape: BoxShape.circle, ), ), ), Expanded( child: Text( text, style: TextStyle( fontSize: 12.sp, height: 1.5, color: AppPalette.textSecondary(isDark), ), ), ), ], ), ); } }