### 详细变更:
1. **文档与配置**:更新AGENTS.md添加命令超时约束,升级Rive依赖至0.14.7并替换平台插件引用
2. **UI优化**:重构AppInfo页面布局、移除图表冗余配置、锁定部分系统设置项
3. **功能增强**:
- 新增工具面板拖拽状态管理与介绍弹窗
- 新增进度页面编辑/重排/清空用户进度功能
- 新增摇一摇路由作用域拦截逻辑
4. **体验优化**:
- 统一外部链接跳转弹窗,添加文件打开确认逻辑
- 修复设备卡片IP溢出、Android权限声明问题
- 后台任务初始化增加协议校验
5. **代码重构**:拆分工具面板配置、拖拽逻辑与动画参数,优化状态管理代码
6. **工具脚本**:新增协议文件上传脚本
636 lines
20 KiB
Dart
636 lines
20 KiB
Dart
/// ============================================================
|
||
/// 闲言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?,
|
||
);
|
||
}
|
||
}
|