Files
xianyan/lib/core/services/auth/permission_service.dart
Developer adfa0af825 chore: 汇总2026-05-30全量更新
### 详细变更:
1.  **文档与配置**:更新AGENTS.md添加命令超时约束,升级Rive依赖至0.14.7并替换平台插件引用
2.  **UI优化**:重构AppInfo页面布局、移除图表冗余配置、锁定部分系统设置项
3.  **功能增强**:
    - 新增工具面板拖拽状态管理与介绍弹窗
    - 新增进度页面编辑/重排/清空用户进度功能
    - 新增摇一摇路由作用域拦截逻辑
4.  **体验优化**:
    - 统一外部链接跳转弹窗,添加文件打开确认逻辑
    - 修复设备卡片IP溢出、Android权限声明问题
    - 后台任务初始化增加协议校验
5.  **代码重构**:拆分工具面板配置、拖拽逻辑与动画参数,优化状态管理代码
6.  **工具脚本**:新增协议文件上传脚本
2026-05-30 05:29:50 +08:00

636 lines
20 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// ============================================================
/// 闲言APP — 权限管理服务
/// 创建时间: 2026-04-23
/// 更新时间: 2026-05-30
/// 作用: 统一管理应用权限请求,支持相机/相册/通知/位置/蓝牙/附近设备/麦克风/存储/网络/剪贴板/分享权限
/// 上次更新: 更新存储权限描述(区分READ/WRITE的API级别必要性)
/// ============================================================
import 'dart:io';
import 'package:flutter/cupertino.dart';
import 'package:permission_handler/permission_handler.dart';
import '../../storage/kv_storage.dart';
import '../device/shake_detector.dart';
/// 权限状态枚举
enum AppPermissionStatus {
granted('已授权', true),
denied('未授权', false),
permanentlyDenied('已拒绝', false),
notDetermined('未请求', false),
restricted('受限', false);
const AppPermissionStatus(this.label, this.isGranted);
final String label;
final bool isGranted;
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.label, this.icon);
final String label;
final IconData icon;
}
/// 权限类型枚举
enum AppPermission {
camera(
'相机',
Permission.camera,
CupertinoIcons.camera_fill,
'用于拍照制作壁纸、扫描二维码、文件传输扫码配对。仅在您主动使用相关功能时请求,不会后台调用。',
Color(0xFF34C759),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['壁纸制作 — 拍照添加图片', '二维码 — 扫描登录/配对', '文件传输 — 扫码连接设备'],
),
photos(
'相册与存储',
Permission.photos,
CupertinoIcons.photo_fill,
'用于选择图片制作壁纸、保存作品到本地相册、设置头像。仅访问您选中的图片,不会读取全部相册。',
Color(0xFF6C63FF),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['壁纸制作 — 选择图片', '保存卡片 — 保存到相册', '个人中心 — 设置头像'],
),
notification(
'通知',
Permission.notification,
CupertinoIcons.bell_fill,
'用于推送每日推荐句子、签到提醒、系统公告、文件传输状态和互动消息。您可以随时在系统设置中关闭。',
Color(0xFFFF3B30),
isRequired: true,
group: PermissionGroup.required,
usageScenes: ['每日推荐 — 定时推送', '签到提醒 — 每日提醒', '文件传输 — 传输状态', '互动消息 — 点赞评论'],
),
location(
'位置(粗略)',
Permission.location,
CupertinoIcons.location_fill,
'用于获取天气信息和节气提醒,仅使用粗略位置(城市级),不获取精确位置,不会后台追踪。',
Color(0xFF007AFF),
usageScenes: ['天气信息 — 当前城市天气', '节气提醒 — 当地节气推送'],
),
bluetooth(
'蓝牙',
Permission.bluetooth,
CupertinoIcons.bluetooth,
'用于文件传输助手的蓝牙配对和设备发现,仅在您使用文件传输功能时请求。',
Color(0xFF5AC8FA),
usageScenes: ['文件传输 — 蓝牙配对', '设备发现 — 附近设备搜索'],
),
nearbyDevices(
'附近设备',
Permission.nearbyWifiDevices,
CupertinoIcons.antenna_radiowaves_left_right,
'用于文件传输助手的局域网设备发现和连接,仅在您使用文件传输功能时请求。',
Color(0xFF64D2FF),
usageScenes: ['文件传输 — 局域网发现', '设备连接 — WiFi直连'],
),
microphone(
'麦克风',
Permission.microphone,
CupertinoIcons.mic_fill,
'用于语音朗读句子、语音搜索、AI对话语音输入。仅在您主动使用语音功能时请求不会后台录音。',
Color(0xFFFF3B30),
usageScenes: ['语音朗读 — 朗读句子', '语音搜索 — 语音输入关键词', 'AI对话 — 语音输入消息'],
),
storage(
'存储空间',
Permission.storage,
CupertinoIcons.folder_fill,
'用于保存编辑的卡片、壁纸到本地导出字体文件和数据。仅Android 9及以下(API≤29)需要写入权限Android 10+使用分区存储Android 12及以下(API≤32)需要读取权限Android 13+由相册权限替代。',
Color(0xFFFF9500),
usageScenes: [
'保存卡片 — 导出到本地',
'壁纸设置 — 保存壁纸',
'字体管理 — 下载字体文件',
'数据导出 — 导出用户数据',
],
),
network(
'网络连接',
Permission.notification,
CupertinoIcons.wifi,
'闲言需要网络连接来获取句子、同步数据和推送通知。请在系统设置中确保网络权限已开启。',
Color(0xFF007AFF),
isRequired: true,
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['句子获取 — 加载每日推荐', '数据同步 — 云端同步', '推送通知 — 接收消息'],
),
clipboard(
'剪贴板',
Permission.notification,
CupertinoIcons.doc_on_clipboard_fill,
'用于复制句子到剪贴板、粘贴文本到编辑器。应用仅在您主动操作时访问剪贴板,不会自动读取。',
Color(0xFFAF52DE),
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['复制句子 — 一键复制', '编辑器 — 粘贴文本', '搜索 — 粘贴关键词'],
),
share(
'分享能力',
Permission.notification,
CupertinoIcons.share,
'允许应用通过系统分享面板将内容分享到其他应用',
Color(0xFF007AFF),
isVirtual: true,
group: PermissionGroup.system,
usageScenes: ['句子分享 — 分享到微信/QQ', '卡片分享 — 分享到社交平台', '日志导出 — 分享日志文件'],
),
shake(
'摇一摇',
Permission.notification,
CupertinoIcons.arrow_counterclockwise,
'摇晃手机触发特定功能,如换句、刷新等',
Color(0xFF5856D6),
isVirtual: true,
usageScenes: ['切换每日推荐句子', '刷新内容', '互动彩蛋'],
);
const AppPermission(
this.label,
this.permission,
this.icon,
this.description,
this.color, {
this.isRequired = false,
this.isVirtual = false,
this.group = PermissionGroup.optional,
this.usageScenes = const [],
});
final String label;
final Permission permission;
final IconData icon;
final String description;
final Color color;
final bool isRequired;
final bool isVirtual;
final PermissionGroup group;
final List<String> usageScenes;
/// Android 13+ 不需要 storage 权限(由 photos 替代)
bool get isPlatformRelevant {
if (this == AppPermission.storage) {
if (!Platform.isAndroid) return false;
final sdkInt = _androidSdkInt;
return sdkInt != null && sdkInt <= 32;
}
return true;
}
static int? get _androidSdkInt {
try {
return int.tryParse(Platform.version.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 (_) {}
}
}
/// 记录权限使用
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.label}');
} 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;
}
final result = await perm.permission.request();
if (result.isGranted) {
_log.i('${perm.label} 权限已授予');
return true;
}
if (result.isPermanentlyDenied) {
_log.w('⚠️ ${perm.label} 权限被永久拒绝,引导用户前往系统设置');
if (context.mounted) {
_showSettingsDialog(context, perm);
}
return false;
}
if (result.isDenied) {
_log.w('⚠️ ${perm.label} 权限被拒绝');
if (context.mounted) {
_showDeniedDialog(context, perm, rationale);
}
return false;
}
if (result.isRestricted) {
_log.w('⚠️ ${perm.label} 权限受限');
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,
rationale: '需要相机权限才能拍照添加图片',
);
/// 快捷方法: 请求相册权限
static Future<bool> requestPhotos(BuildContext context) => requestPermission(
context,
AppPermission.photos,
rationale: '需要相册权限才能选择图片',
);
/// 快捷方法: 请求通知权限
static Future<bool> requestNotification(BuildContext context) =>
requestPermission(
context,
AppPermission.notification,
rationale: '需要通知权限才能推送每日推荐',
);
/// 快捷方法: 请求位置权限
static Future<bool> requestLocation(BuildContext context) =>
requestPermission(
context,
AppPermission.location,
rationale: '需要位置权限才能提供天气和节气信息',
);
/// 快捷方法: 请求麦克风权限
static Future<bool> requestMicrophone(BuildContext context) =>
requestPermission(
context,
AppPermission.microphone,
rationale: '需要麦克风权限才能使用语音功能',
);
/// 打开系统设置
static Future<bool> openSettings() => openAppSettings();
static void _showSettingsDialog(BuildContext context, AppPermission perm) {
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('${perm.label}权限被拒绝'),
content: Text('请在系统设置中开启${perm.label}权限,以使用此功能'),
actions: [
CupertinoDialogAction(
child: const Text('取消'),
onPressed: () => Navigator.pop(context),
),
CupertinoDialogAction(
isDefaultAction: true,
child: const Text('去设置'),
onPressed: () {
Navigator.pop(context);
openAppSettings();
},
),
],
),
);
}
static void _showDeniedDialog(
BuildContext context,
AppPermission perm,
String? rationale,
) {
showCupertinoDialog<void>(
context: context,
builder: (_) => CupertinoAlertDialog(
title: Text('${perm.label}权限请求'),
content: Text(rationale ?? '请允许${perm.label}权限以继续'),
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?,
);
}
}