784 lines
24 KiB
Dart
784 lines
24 KiB
Dart
/// ============================================================
|
||
/// 闲言APP — 权限管理服务
|
||
/// 创建时间: 2026-04-23
|
||
/// 更新时间: 2026-06-06
|
||
/// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限 + iOS ATT授权
|
||
/// 上次更新: 新增requestTrackingPermission()方法,支持iOS App Tracking Transparency授权
|
||
/// ============================================================
|
||
|
||
import 'dart:io';
|
||
|
||
import 'package:flutter/cupertino.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:app_tracking_transparency/app_tracking_transparency.dart';
|
||
import 'package:permission_handler/permission_handler.dart';
|
||
|
||
import '../../storage/kv_storage.dart';
|
||
import '../../utils/platform/platform_utils.dart' as pu;
|
||
import '../../../l10n/translation_resolver.dart';
|
||
import '../../../l10n/types/t_settings_permission.dart';
|
||
import '../device/shake_detector.dart';
|
||
|
||
/// 从 BuildContext 获取权限翻译
|
||
TSettingsPermission _permOf(BuildContext context) {
|
||
final container = ProviderScope.containerOf(context, listen: false);
|
||
return container.read(translationsProvider).settings.permission;
|
||
}
|
||
|
||
/// 权限状态枚举
|
||
enum AppPermissionStatus {
|
||
granted(true),
|
||
denied(false),
|
||
permanentlyDenied(false),
|
||
notDetermined(false),
|
||
restricted(false);
|
||
|
||
const AppPermissionStatus(this.isGranted);
|
||
final bool isGranted;
|
||
|
||
/// 从翻译系统获取状态标签
|
||
String label(BuildContext context) {
|
||
final t = _permOf(context);
|
||
return switch (this) {
|
||
AppPermissionStatus.granted => t.statusGranted,
|
||
AppPermissionStatus.denied => t.statusDenied,
|
||
AppPermissionStatus.permanentlyDenied => t.statusPermanentlyDenied,
|
||
AppPermissionStatus.notDetermined => t.statusNotDetermined,
|
||
AppPermissionStatus.restricted => t.statusRestricted,
|
||
};
|
||
}
|
||
|
||
static AppPermissionStatus fromPermissionStatus(PermissionStatus status) {
|
||
return switch (status) {
|
||
PermissionStatus.granted => AppPermissionStatus.granted,
|
||
PermissionStatus.limited => AppPermissionStatus.granted,
|
||
PermissionStatus.denied => AppPermissionStatus.denied,
|
||
PermissionStatus.permanentlyDenied =>
|
||
AppPermissionStatus.permanentlyDenied,
|
||
PermissionStatus.restricted => AppPermissionStatus.restricted,
|
||
PermissionStatus.provisional => AppPermissionStatus.granted,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 权限分组枚举
|
||
enum PermissionGroup {
|
||
required(CupertinoIcons.lock_shield_fill),
|
||
optional(CupertinoIcons.hand_raised_fill),
|
||
system(CupertinoIcons.gear_solid);
|
||
|
||
const PermissionGroup(this.icon);
|
||
final IconData icon;
|
||
|
||
/// 从翻译系统获取分组标签
|
||
String label(BuildContext context) {
|
||
final t = _permOf(context);
|
||
return switch (this) {
|
||
PermissionGroup.required => t.badgeRequired,
|
||
PermissionGroup.optional => t.badgeOptional,
|
||
PermissionGroup.system => t.badgeSystem,
|
||
};
|
||
}
|
||
}
|
||
|
||
/// 权限类型枚举
|
||
enum AppPermission {
|
||
camera(
|
||
Permission.camera,
|
||
CupertinoIcons.camera_fill,
|
||
Color(0xFF34C759),
|
||
isRequired: true,
|
||
group: PermissionGroup.required,
|
||
),
|
||
photos(
|
||
Permission.photos,
|
||
CupertinoIcons.photo_fill,
|
||
Color(0xFF6C63FF),
|
||
isRequired: true,
|
||
group: PermissionGroup.required,
|
||
),
|
||
notification(
|
||
Permission.notification,
|
||
CupertinoIcons.bell_fill,
|
||
Color(0xFFFF3B30),
|
||
isRequired: true,
|
||
group: PermissionGroup.required,
|
||
),
|
||
location(
|
||
Permission.location,
|
||
CupertinoIcons.location_fill,
|
||
Color(0xFF007AFF),
|
||
),
|
||
nearbyDevices(
|
||
Permission.nearbyWifiDevices,
|
||
CupertinoIcons.antenna_radiowaves_left_right,
|
||
Color(0xFF64D2FF),
|
||
),
|
||
microphone(Permission.microphone, CupertinoIcons.mic_fill, Color(0xFFFF3B30)),
|
||
storage(Permission.storage, CupertinoIcons.folder_fill, Color(0xFFFF9500)),
|
||
network(
|
||
Permission.notification,
|
||
CupertinoIcons.wifi,
|
||
Color(0xFF007AFF),
|
||
isRequired: true,
|
||
isVirtual: true,
|
||
group: PermissionGroup.system,
|
||
),
|
||
clipboard(
|
||
Permission.notification,
|
||
CupertinoIcons.doc_on_clipboard_fill,
|
||
Color(0xFFAF52DE),
|
||
isVirtual: true,
|
||
group: PermissionGroup.system,
|
||
),
|
||
share(
|
||
Permission.notification,
|
||
CupertinoIcons.share,
|
||
Color(0xFF007AFF),
|
||
isVirtual: true,
|
||
group: PermissionGroup.system,
|
||
),
|
||
shake(
|
||
Permission.notification,
|
||
CupertinoIcons.arrow_counterclockwise,
|
||
Color(0xFF5856D6),
|
||
isVirtual: true,
|
||
);
|
||
|
||
const AppPermission(
|
||
this.permission,
|
||
this.icon,
|
||
this.color, {
|
||
this.isRequired = false,
|
||
this.isVirtual = false,
|
||
this.group = PermissionGroup.optional,
|
||
});
|
||
|
||
final Permission permission;
|
||
final IconData icon;
|
||
final Color color;
|
||
final bool isRequired;
|
||
final bool isVirtual;
|
||
final PermissionGroup group;
|
||
|
||
/// 从翻译系统获取权限名称
|
||
String label(BuildContext context) {
|
||
final t = _permOf(context);
|
||
return switch (this) {
|
||
AppPermission.camera => t.permCameraLabel,
|
||
AppPermission.photos => t.permPhotosLabel,
|
||
AppPermission.notification => t.permNotificationLabel,
|
||
AppPermission.location => t.permLocationLabel,
|
||
AppPermission.nearbyDevices => t.permNearbyDevicesLabel,
|
||
AppPermission.microphone => t.permMicrophoneLabel,
|
||
AppPermission.storage => t.permStorageLabel,
|
||
AppPermission.network => t.permNetworkLabel,
|
||
AppPermission.clipboard => t.permClipboardLabel,
|
||
AppPermission.share => t.permShareLabel,
|
||
AppPermission.shake => t.permShakeLabel,
|
||
};
|
||
}
|
||
|
||
/// 从翻译系统获取权限描述
|
||
String description(BuildContext context) {
|
||
final t = _permOf(context);
|
||
return switch (this) {
|
||
AppPermission.camera => t.permCameraDesc,
|
||
AppPermission.photos => t.permPhotosDesc,
|
||
AppPermission.notification => t.permNotificationDesc,
|
||
AppPermission.location => t.permLocationDesc,
|
||
AppPermission.nearbyDevices => t.permNearbyDevicesDesc,
|
||
AppPermission.microphone => t.permMicrophoneDesc,
|
||
AppPermission.storage => t.permStorageDesc,
|
||
AppPermission.network => t.permNetworkDesc,
|
||
AppPermission.clipboard => t.permClipboardDesc,
|
||
AppPermission.share => t.permShareDesc,
|
||
AppPermission.shake => t.permShakeDesc,
|
||
};
|
||
}
|
||
|
||
/// 从翻译系统获取使用场景列表(管道符分隔)
|
||
List<String> usageScenes(BuildContext context) {
|
||
final t = _permOf(context);
|
||
final raw = switch (this) {
|
||
AppPermission.camera => t.permCameraUsage,
|
||
AppPermission.photos => t.permPhotosUsage,
|
||
AppPermission.notification => t.permNotificationUsage,
|
||
AppPermission.location => t.permLocationUsage,
|
||
AppPermission.nearbyDevices => t.permNearbyDevicesUsage,
|
||
AppPermission.microphone => t.permMicrophoneUsage,
|
||
AppPermission.storage => t.permStorageUsage,
|
||
AppPermission.network => t.permNetworkUsage,
|
||
AppPermission.clipboard => t.permClipboardUsage,
|
||
AppPermission.share => t.permShareUsage,
|
||
AppPermission.shake => t.permShakeUsage,
|
||
};
|
||
if (raw.isEmpty) return const [];
|
||
return raw.split('|');
|
||
}
|
||
|
||
/// 从翻译系统获取拒绝影响说明
|
||
String denialImpact(BuildContext context) {
|
||
final t = _permOf(context);
|
||
return switch (this) {
|
||
AppPermission.camera => t.permCameraDenial,
|
||
AppPermission.photos => t.permPhotosDenial,
|
||
AppPermission.notification => t.permNotificationDenial,
|
||
AppPermission.location => t.permLocationDenial,
|
||
AppPermission.nearbyDevices => t.permNearbyDevicesDenial,
|
||
AppPermission.microphone => t.permMicrophoneDenial,
|
||
AppPermission.storage => t.permStorageDenial,
|
||
AppPermission.network => t.permNetworkDenial,
|
||
AppPermission.clipboard => t.permClipboardDenial,
|
||
AppPermission.share => t.permShareDenial,
|
||
AppPermission.shake => t.permShakeDenial,
|
||
};
|
||
}
|
||
|
||
/// Android 13+ 不需要 storage 权限(由 photos 替代)
|
||
bool get isPlatformRelevant {
|
||
if (this == AppPermission.storage) {
|
||
if (!pu.isAndroid) return false;
|
||
final sdkInt = _androidSdkInt;
|
||
return sdkInt != null && sdkInt <= 32;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
static int? get _androidSdkInt {
|
||
try {
|
||
return int.tryParse(pu.platformVersion.split('.').first);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 权限管理服务 — iOS 风格权限请求
|
||
class PermissionService {
|
||
static final _log = _PermissionLogger();
|
||
|
||
// ============================================================
|
||
// 权限使用统计
|
||
// ============================================================
|
||
|
||
static const _usageStatsKey = 'permission_usage_stats';
|
||
|
||
static const _shakeEnabledKey = 'shake_enabled';
|
||
|
||
static bool get isShakeEnabled =>
|
||
KvStorage.getBool(_shakeEnabledKey, box: HiveBoxNames.userPrefs) ?? true;
|
||
|
||
static Future<void> setShakeEnabled(bool enabled) async {
|
||
await KvStorage.setBool(
|
||
_shakeEnabledKey,
|
||
enabled,
|
||
box: HiveBoxNames.userPrefs,
|
||
);
|
||
if (!enabled) {
|
||
try {
|
||
ShakeDetector.instance.stop();
|
||
} catch (_) {}
|
||
} else {
|
||
// 重新启用摇一摇时重启检测器
|
||
try {
|
||
ShakeDetector.instance.start();
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
|
||
/// 记录权限使用
|
||
static void recordUsage(AppPermission permission) {
|
||
try {
|
||
final stats = getUsageStats();
|
||
final key = permission.name;
|
||
final existing = stats[key];
|
||
if (existing != null) {
|
||
existing['count'] = (existing['count'] as int) + 1;
|
||
existing['lastUsed'] = DateTime.now().toIso8601String();
|
||
} else {
|
||
stats[key] = {
|
||
'count': 1,
|
||
'lastUsed': DateTime.now().toIso8601String(),
|
||
'firstUsed': DateTime.now().toIso8601String(),
|
||
};
|
||
}
|
||
KvStorage.setString(
|
||
_usageStatsKey,
|
||
_encodeStats(stats),
|
||
box: HiveBoxNames.userPrefs,
|
||
);
|
||
_log.i('权限使用记录: ${permission.name}');
|
||
} catch (e) {
|
||
_log.e('权限使用记录失败', e);
|
||
}
|
||
}
|
||
|
||
/// 获取使用统计
|
||
static Map<String, Map<String, dynamic>> getUsageStats() {
|
||
try {
|
||
final raw = KvStorage.getString(
|
||
_usageStatsKey,
|
||
box: HiveBoxNames.userPrefs,
|
||
);
|
||
if (raw == null || raw.isEmpty) return {};
|
||
return _decodeStats(raw);
|
||
} catch (e) {
|
||
_log.e('获取权限使用统计失败', e);
|
||
return {};
|
||
}
|
||
}
|
||
|
||
/// 获取单个权限的使用统计
|
||
static PermissionUsageStat? getPermissionUsage(AppPermission permission) {
|
||
final stats = getUsageStats();
|
||
final data = stats[permission.name];
|
||
if (data == null) return null;
|
||
return PermissionUsageStat.fromJson(data);
|
||
}
|
||
|
||
/// 获取使用频率标签
|
||
static String getFrequencyLabel(AppPermission permission) {
|
||
final stat = getPermissionUsage(permission);
|
||
if (stat == null) return '未使用';
|
||
final count = stat.count;
|
||
if (count <= 2) return '低';
|
||
if (count <= 10) return '中';
|
||
return '高';
|
||
}
|
||
|
||
/// 获取使用频率等级 (0-3)
|
||
static int getFrequencyLevel(AppPermission permission) {
|
||
final stat = getPermissionUsage(permission);
|
||
if (stat == null) return 0;
|
||
final count = stat.count;
|
||
if (count <= 2) return 1;
|
||
if (count <= 10) return 2;
|
||
return 3;
|
||
}
|
||
|
||
/// 清除使用统计
|
||
static void clearUsageStats() {
|
||
KvStorage.remove(_usageStatsKey, box: HiveBoxNames.userPrefs);
|
||
}
|
||
|
||
static String _encodeStats(Map<String, Map<String, dynamic>> stats) {
|
||
final buffer = StringBuffer();
|
||
stats.forEach((key, value) {
|
||
if (buffer.isNotEmpty) buffer.write(';');
|
||
buffer.write(
|
||
'$key=${value['count']},${value['lastUsed']},${value['firstUsed'] ?? ''}',
|
||
);
|
||
});
|
||
return buffer.toString();
|
||
}
|
||
|
||
static Map<String, Map<String, dynamic>> _decodeStats(String raw) {
|
||
final result = <String, Map<String, dynamic>>{};
|
||
if (raw.isEmpty) return result;
|
||
final entries = raw.split(';');
|
||
for (final entry in entries) {
|
||
final parts = entry.split('=');
|
||
if (parts.length != 2) continue;
|
||
final key = parts[0];
|
||
final values = parts[1].split(',');
|
||
if (values.length < 2) continue;
|
||
result[key] = {
|
||
'count': int.tryParse(values[0]) ?? 0,
|
||
'lastUsed': values[1],
|
||
'firstUsed': values.length > 2 ? values[2] : '',
|
||
};
|
||
}
|
||
return result;
|
||
}
|
||
|
||
/// 检查单个权限状态
|
||
static Future<AppPermissionStatus> checkStatus(AppPermission perm) async {
|
||
if (perm.isVirtual) {
|
||
return _checkVirtualStatus(perm);
|
||
}
|
||
try {
|
||
final status = await perm.permission.status;
|
||
return AppPermissionStatus.fromPermissionStatus(status);
|
||
} catch (e) {
|
||
_log.e('权限状态查询异常', e);
|
||
return AppPermissionStatus.notDetermined;
|
||
}
|
||
}
|
||
|
||
/// 虚拟权限状态检查
|
||
static Future<AppPermissionStatus> _checkVirtualStatus(
|
||
AppPermission perm,
|
||
) async {
|
||
switch (perm) {
|
||
case AppPermission.network:
|
||
return AppPermissionStatus.granted;
|
||
case AppPermission.clipboard:
|
||
return AppPermissionStatus.granted;
|
||
case AppPermission.share:
|
||
return AppPermissionStatus.granted;
|
||
case AppPermission.shake:
|
||
return isShakeEnabled
|
||
? AppPermissionStatus.granted
|
||
: AppPermissionStatus.denied;
|
||
default:
|
||
return AppPermissionStatus.granted;
|
||
}
|
||
}
|
||
|
||
/// 批量查询所有权限状态(过滤平台不相关权限)
|
||
static Future<Map<AppPermission, AppPermissionStatus>>
|
||
checkAllStatus() async {
|
||
final results = <AppPermission, AppPermissionStatus>{};
|
||
for (final perm in AppPermission.values) {
|
||
if (!perm.isPlatformRelevant) continue;
|
||
results[perm] = await checkStatus(perm);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/// 检查并请求单个权限
|
||
static Future<bool> requestPermission(
|
||
BuildContext context,
|
||
AppPermission perm, {
|
||
String? rationale,
|
||
}) async {
|
||
if (perm.isVirtual) return true;
|
||
try {
|
||
final status = await perm.permission.status;
|
||
|
||
if (status.isGranted) return true;
|
||
if (status.isPermanentlyDenied) {
|
||
if (context.mounted) {
|
||
_showSettingsDialog(context, perm);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (context.mounted) {
|
||
final userConfirmed = await _showRationaleDialog(
|
||
context,
|
||
perm,
|
||
rationale: rationale,
|
||
);
|
||
if (!userConfirmed) return false;
|
||
}
|
||
|
||
final result = await perm.permission.request();
|
||
if (result.isGranted) {
|
||
_log.i('✅ ${perm.name} 权限已授予');
|
||
return true;
|
||
}
|
||
|
||
if (result.isPermanentlyDenied) {
|
||
_log.w('⚠️ ${perm.name} 权限被永久拒绝,引导用户前往系统设置');
|
||
if (context.mounted) {
|
||
_showSettingsDialog(context, perm);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (result.isDenied) {
|
||
_log.w('⚠️ ${perm.name} 权限被拒绝');
|
||
if (context.mounted) {
|
||
_showDeniedDialog(context, perm, rationale);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (result.isRestricted) {
|
||
_log.w('⚠️ ${perm.name} 权限受限');
|
||
if (context.mounted) {
|
||
_showSettingsDialog(context, perm);
|
||
}
|
||
return false;
|
||
}
|
||
|
||
if (context.mounted) {
|
||
_showDeniedDialog(context, perm, rationale);
|
||
}
|
||
return false;
|
||
} catch (e) {
|
||
_log.e('权限请求异常', e);
|
||
if (context.mounted) {
|
||
_showSettingsDialog(context, perm);
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 批量检查并请求多个权限
|
||
static Future<Map<AppPermission, bool>> requestPermissions(
|
||
BuildContext context,
|
||
List<AppPermission> permissions, {
|
||
Map<AppPermission, String>? rationales,
|
||
}) async {
|
||
final results = <AppPermission, bool>{};
|
||
for (final perm in permissions) {
|
||
if (perm.isVirtual) {
|
||
results[perm] = true;
|
||
continue;
|
||
}
|
||
results[perm] = await requestPermission(
|
||
context,
|
||
perm,
|
||
rationale: rationales?[perm],
|
||
);
|
||
}
|
||
return results;
|
||
}
|
||
|
||
/// 快捷方法: 请求相机权限
|
||
static Future<bool> requestCamera(BuildContext context) =>
|
||
requestPermission(context, AppPermission.camera);
|
||
|
||
/// 快捷方法: 请求相册权限
|
||
static Future<bool> requestPhotos(BuildContext context) =>
|
||
requestPermission(context, AppPermission.photos);
|
||
|
||
/// 快捷方法: 请求通知权限
|
||
static Future<bool> requestNotification(BuildContext context) =>
|
||
requestPermission(context, AppPermission.notification);
|
||
|
||
/// 快捷方法: 请求位置权限
|
||
static Future<bool> requestLocation(BuildContext context) =>
|
||
requestPermission(context, AppPermission.location);
|
||
|
||
/// 快捷方法: 请求麦克风权限
|
||
static Future<bool> requestMicrophone(BuildContext context) =>
|
||
requestPermission(context, AppPermission.microphone);
|
||
|
||
/// 打开系统设置
|
||
static Future<bool> openSettings() => openAppSettings();
|
||
|
||
// ============================================================
|
||
// iOS App Tracking Transparency (ATT)
|
||
// ============================================================
|
||
|
||
/// 请求App Tracking Transparency授权(仅iOS)
|
||
///
|
||
/// 在iOS 14.5+上请求用户授权追踪,其他平台直接返回true。
|
||
/// 应在用户同意隐私协议后调用。
|
||
/// 返回true表示用户授权追踪,false表示拒绝或非iOS平台。
|
||
static Future<bool> requestTrackingPermission() async {
|
||
if (!Platform.isIOS) return true;
|
||
try {
|
||
final status =
|
||
await AppTrackingTransparency.requestTrackingAuthorization();
|
||
_log.i('ATT授权状态: $status');
|
||
return status == TrackingStatus.authorized;
|
||
} catch (e) {
|
||
_log.e('ATT请求失败', e);
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// 检查当前ATT授权状态(仅iOS)
|
||
static Future<TrackingStatus> getTrackingStatus() async {
|
||
if (!Platform.isIOS) return TrackingStatus.authorized;
|
||
try {
|
||
return await AppTrackingTransparency.trackingAuthorizationStatus;
|
||
} catch (e) {
|
||
_log.e('ATT状态查询失败', e);
|
||
return TrackingStatus.notSupported;
|
||
}
|
||
}
|
||
|
||
static void _showSettingsDialog(BuildContext context, AppPermission perm) {
|
||
final permLabel = perm.label(context);
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (_) => CupertinoAlertDialog(
|
||
title: Text('$permLabel权限被拒绝'),
|
||
content: Text('请在系统设置中开启$permLabel权限,以使用此功能'),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: const Text('取消'),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: const Text('去设置'),
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
openAppSettings();
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 权限申请前的说明对话框
|
||
static Future<bool> _showRationaleDialog(
|
||
BuildContext context,
|
||
AppPermission perm, {
|
||
String? rationale,
|
||
}) async {
|
||
final permLabel = perm.label(context);
|
||
final purpose = rationale ?? perm.description(context);
|
||
final impact = perm.denialImpact(context);
|
||
final scenes = perm.usageScenes(context);
|
||
|
||
return showCupertinoDialog<bool>(
|
||
context: context,
|
||
builder: (_) => CupertinoAlertDialog(
|
||
title: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(perm.icon, size: 20, color: perm.color),
|
||
const SizedBox(width: 8),
|
||
Text('申请$permLabel权限'),
|
||
],
|
||
),
|
||
content: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'📋 用途',
|
||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(purpose, style: const TextStyle(fontSize: 13)),
|
||
if (scenes.isNotEmpty) ...[
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'🔑 使用场景',
|
||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 4),
|
||
...scenes
|
||
.take(3)
|
||
.map(
|
||
(s) => Padding(
|
||
padding: const EdgeInsets.only(left: 8, bottom: 2),
|
||
child: Text('• $s', style: const TextStyle(fontSize: 12)),
|
||
),
|
||
),
|
||
],
|
||
const SizedBox(height: 8),
|
||
const Text(
|
||
'⚠️ 拒绝影响',
|
||
style: TextStyle(fontWeight: FontWeight.w600, fontSize: 13),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(impact, style: const TextStyle(fontSize: 13)),
|
||
],
|
||
),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: const Text('暂不'),
|
||
onPressed: () => Navigator.pop(context, false),
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: const Text('允许'),
|
||
onPressed: () => Navigator.pop(context, true),
|
||
),
|
||
],
|
||
),
|
||
).then((v) => v ?? false);
|
||
}
|
||
|
||
static void _showDeniedDialog(
|
||
BuildContext context,
|
||
AppPermission perm,
|
||
String? rationale,
|
||
) {
|
||
final permLabel = perm.label(context);
|
||
showCupertinoDialog<void>(
|
||
context: context,
|
||
builder: (_) => CupertinoAlertDialog(
|
||
title: Text('$permLabel权限请求'),
|
||
content: Text(rationale ?? '请允许$permLabel权限以继续'),
|
||
actions: [
|
||
CupertinoDialogAction(
|
||
child: const Text('取消'),
|
||
onPressed: () => Navigator.pop(context),
|
||
),
|
||
CupertinoDialogAction(
|
||
child: const Text('去设置'),
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
openAppSettings();
|
||
},
|
||
),
|
||
CupertinoDialogAction(
|
||
isDefaultAction: true,
|
||
child: const Text('再次请求'),
|
||
onPressed: () {
|
||
Navigator.pop(context);
|
||
requestPermission(context, perm, rationale: rationale);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 简易日志工具
|
||
class _PermissionLogger {
|
||
void i(String msg) => debugPrint('[PermissionService] $msg');
|
||
void w(String msg) => debugPrint('[PermissionService⚠️] $msg');
|
||
void e(String msg, [Object? error]) =>
|
||
debugPrint('[PermissionService❌] $msg $error');
|
||
}
|
||
|
||
/// 权限使用统计数据
|
||
class PermissionUsageStat {
|
||
const PermissionUsageStat({
|
||
required this.count,
|
||
required this.lastUsed,
|
||
this.firstUsed,
|
||
});
|
||
|
||
final int count;
|
||
final String lastUsed;
|
||
final String? firstUsed;
|
||
|
||
DateTime? get lastUsedDateTime {
|
||
try {
|
||
return DateTime.parse(lastUsed);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
DateTime? get firstUsedDateTime {
|
||
if (firstUsed == null || firstUsed!.isEmpty) return null;
|
||
try {
|
||
return DateTime.parse(firstUsed!);
|
||
} catch (_) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
String get frequencyLabel {
|
||
if (count <= 2) return '低';
|
||
if (count <= 10) return '中';
|
||
return '高';
|
||
}
|
||
|
||
String get relativeLastUsed {
|
||
final dt = lastUsedDateTime;
|
||
if (dt == null) return '未知';
|
||
final diff = DateTime.now().difference(dt);
|
||
if (diff.inMinutes < 1) return '刚刚';
|
||
if (diff.inHours < 1) return '${diff.inMinutes}分钟前';
|
||
if (diff.inDays < 1) return '${diff.inHours}小时前';
|
||
if (diff.inDays < 7) return '${diff.inDays}天前';
|
||
return '${dt.month}月${dt.day}日';
|
||
}
|
||
|
||
factory PermissionUsageStat.fromJson(Map<String, dynamic> json) {
|
||
return PermissionUsageStat(
|
||
count: json['count'] as int? ?? 0,
|
||
lastUsed: json['lastUsed'] as String? ?? '',
|
||
firstUsed: json['firstUsed'] as String?,
|
||
);
|
||
}
|
||
}
|