Commit ac90d982 by tanghuan

权限提示初版

1 parent 6426f18f
...@@ -2,11 +2,12 @@ import 'dart:async'; ...@@ -2,11 +2,12 @@ import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:app_settings/app_settings.dart';
import 'package:appframe/config/constant.dart'; import 'package:appframe/config/constant.dart';
import 'package:appframe/config/locator.dart'; import 'package:appframe/config/locator.dart';
import 'package:appframe/config/routes.dart'; import 'package:appframe/config/routes.dart';
import 'package:appframe/ui/widgets/wechat_qr_bind_dialog.dart'; import 'package:appframe/ui/widgets/wechat_qr_bind_dialog.dart';
import 'package:appframe/ui/widgets/permission/permission_intro_dialog.dart';
import 'package:appframe/ui/widgets/permission/permission_settings_dialog.dart';
import 'package:appframe/data/models/message/h5_message.dart'; import 'package:appframe/data/models/message/h5_message.dart';
import 'package:appframe/services/dispatcher.dart'; import 'package:appframe/services/dispatcher.dart';
import 'package:appframe/services/im_service.dart'; import 'package:appframe/services/im_service.dart';
...@@ -75,6 +76,9 @@ class WebState extends Equatable { ...@@ -75,6 +76,9 @@ class WebState extends Equatable {
final bool chooseVideoCmdFlag; final bool chooseVideoCmdFlag;
final String chooseVideoCmdMessage; final String chooseVideoCmdMessage;
/// 麦克风权限检查,由页面监听后传入 context
final bool microphonePermissionFlag;
/// wechatQrBindCmd /// wechatQrBindCmd
final bool wechatQrBindCmdFlag; final bool wechatQrBindCmdFlag;
...@@ -111,6 +115,7 @@ class WebState extends Equatable { ...@@ -111,6 +115,7 @@ class WebState extends Equatable {
this.chooseVideoCmdFlag = false, this.chooseVideoCmdFlag = false,
this.chooseVideoCmdMessage = '', this.chooseVideoCmdMessage = '',
this.wechatQrBindCmdFlag = false, this.wechatQrBindCmdFlag = false,
this.microphonePermissionFlag = false,
this.testMsg = '', this.testMsg = '',
}); });
...@@ -144,6 +149,7 @@ class WebState extends Equatable { ...@@ -144,6 +149,7 @@ class WebState extends Equatable {
bool? chooseVideoCmdFlag, bool? chooseVideoCmdFlag,
String? chooseVideoCmdMessage, String? chooseVideoCmdMessage,
bool? wechatQrBindCmdFlag, bool? wechatQrBindCmdFlag,
bool? microphonePermissionFlag,
String? testMsg, String? testMsg,
}) { }) {
return WebState( return WebState(
...@@ -176,6 +182,7 @@ class WebState extends Equatable { ...@@ -176,6 +182,7 @@ class WebState extends Equatable {
chooseVideoCmdFlag: chooseVideoCmdFlag ?? this.chooseVideoCmdFlag, chooseVideoCmdFlag: chooseVideoCmdFlag ?? this.chooseVideoCmdFlag,
chooseVideoCmdMessage: chooseVideoCmdMessage ?? this.chooseVideoCmdMessage, chooseVideoCmdMessage: chooseVideoCmdMessage ?? this.chooseVideoCmdMessage,
wechatQrBindCmdFlag: wechatQrBindCmdFlag ?? this.wechatQrBindCmdFlag, wechatQrBindCmdFlag: wechatQrBindCmdFlag ?? this.wechatQrBindCmdFlag,
microphonePermissionFlag: microphonePermissionFlag ?? this.microphonePermissionFlag,
testMsg: testMsg ?? this.testMsg, testMsg: testMsg ?? this.testMsg,
); );
} }
...@@ -210,6 +217,7 @@ class WebState extends Equatable { ...@@ -210,6 +217,7 @@ class WebState extends Equatable {
chooseVideoCmdFlag, chooseVideoCmdFlag,
chooseVideoCmdMessage, chooseVideoCmdMessage,
wechatQrBindCmdFlag, wechatQrBindCmdFlag,
microphonePermissionFlag,
testMsg, testMsg,
]; ];
} }
...@@ -220,6 +228,8 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -220,6 +228,8 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
HttpServer? _server; HttpServer? _server;
PlayerService? _playerService; PlayerService? _playerService;
RecorderService? _recorderService; RecorderService? _recorderService;
Completer<String?>? _microphonePermissionCompleter;
bool _isClosing = false;
WebViewController get controller => _controller; WebViewController get controller => _controller;
...@@ -967,7 +977,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -967,7 +977,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
if (await _checkGalleryPermanentlyDenied()) { if (await _checkGalleryPermanentlyDenied()) {
var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'}; var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'};
_sendResponse(resp); _sendResponse(resp);
_permissionLead(context, '相册权限'); showPermissionSettingsDialog(context, '相册权限');
return;
}
// 首次使用相册前告知用户用途,用户暂不开启则直接返回
if (!await _confirmGalleryIntro(context)) {
var cancelResp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'cancel'};
_sendResponse(cancelResp);
return; return;
} }
...@@ -1016,7 +1033,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -1016,7 +1033,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
if (await _checkCameraPermanentlyDenied()) { if (await _checkCameraPermanentlyDenied()) {
var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'}; var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'};
_sendResponse(resp); _sendResponse(resp);
_permissionLead(context, '相机权限'); showPermissionSettingsDialog(context, '相机权限');
return;
}
// 首次使用相机前告知用户用途,用户暂不开启则直接返回
if (!await _confirmCameraIntro(context)) {
var cancelResp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'cancel'};
_sendResponse(cancelResp);
return; return;
} }
...@@ -1067,34 +1091,124 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -1067,34 +1091,124 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
}; };
} }
/// 发出麦克风权限检查请求,等待页面传入 context 并完成弹窗交互。
/// 返回 null 表示允许后续系统授权,cancel / no auth 表示中断录音。
Future<String?> checkMicrophonePermission() {
if (isClosed || _isClosing || _microphonePermissionCompleter != null) {
return Future.value('cancel');
}
final completer = Completer<String?>();
_microphonePermissionCompleter = completer;
emit(state.copyWith(microphonePermissionFlag: true));
// 仅限制等待页面接收请求的时间,不限制用户阅读或操作弹窗的时间。
final timer = Timer(const Duration(seconds: 5), () {
if (identical(_microphonePermissionCompleter, completer) && state.microphonePermissionFlag) {
if (!isClosed && !_isClosing) {
emit(state.copyWith(microphonePermissionFlag: false));
}
_completeMicrophonePermission(completer, 'cancel');
}
});
return completer.future.whenComplete(timer.cancel);
}
/// 由 WebPage.listener 调用,只使用页面本次传入的 context。
Future<void> handleMicrophonePermission(BuildContext context) async {
final completer = _microphonePermissionCompleter;
if (completer == null || !state.microphonePermissionFlag || isClosed || _isClosing) {
return;
}
// 先消费标记,避免其它状态更新导致重复处理同一个请求。
emit(state.copyWith(microphonePermissionFlag: false));
String? errMsg = 'cancel';
try {
if (!context.mounted || ModalRoute.of(context)?.isCurrent == false) {
return;
}
final status = await Permission.microphone.status;
if (!context.mounted || isClosed || _isClosing || completer.isCompleted) {
return;
}
if (ModalRoute.of(context)?.isCurrent == false) {
return;
}
if (status == PermissionStatus.permanentlyDenied) {
await showPermissionSettingsDialog(context, '麦克风权限');
errMsg = 'no auth';
return;
}
final agreed = await _confirmPermissionIntro(context, '麦克风权限', Constant.hasShownMicrophoneIntroKey);
if (!context.mounted || isClosed || _isClosing || completer.isCompleted) {
return;
}
if (ModalRoute.of(context)?.isCurrent == false) {
return;
}
if (agreed) {
errMsg = null;
}
} catch (e) {
debugPrint('麦克风权限检查失败: $e');
errMsg = 'no auth';
} finally {
_completeMicrophonePermission(completer, errMsg);
}
}
void _completeMicrophonePermission(Completer<String?> completer, String? errMsg) {
if (!completer.isCompleted) {
completer.complete(errMsg);
}
if (identical(_microphonePermissionCompleter, completer)) {
_microphonePermissionCompleter = null;
}
}
/// ///
/// 引导打开权限设置 /// 首次使用某个权限前的用途说明
/// ///
Future<void> _permissionLead(BuildContext context, String permission) async { /// 已记录标记则直接放行;用户点击「暂不开启」时不写入标记,下次仍会再次提示。
showDialog( /// 返回 true 表示可以继续后续的权限申请与业务处理。
context: context, ///
builder: (BuildContext context) { Future<bool> _confirmPermissionIntro(BuildContext context, String permission, String preferenceKey) async {
return AlertDialog( if (!context.mounted || isClosed || _isClosing) {
title: Text('权限设置'), return false;
content: Text('$permission已被拒绝,请到设置中手动开启权限'), }
actions: <Widget>[ final sharedPreferences = getIt.get<SharedPreferences>();
TextButton( if (sharedPreferences.getBool(preferenceKey) ?? false) {
child: Text('取消'), return true;
onPressed: () { }
Navigator.of(context).pop();
}, if (!await showPermissionIntroDialog(context, permission)) {
), return false;
TextButton( }
child: Text('去设置'), if (!context.mounted || isClosed || _isClosing) {
onPressed: () { return false;
Navigator.of(context).pop(); }
AppSettings.openAppSettings(asAnotherTask: true); // 引导用户到应用设置页面
}, // 记录用户已同意继续,下次使用该权限时不再弹框告知
), await sharedPreferences.setBool(preferenceKey, true);
], return true;
); }
},
); ///
/// 首次使用相机前的用途说明
///
Future<bool> _confirmCameraIntro(BuildContext context) {
return _confirmPermissionIntro(context, '相机权限', Constant.hasShownCameraIntroKey);
}
///
/// 首次使用相册前的用途说明
///
Future<bool> _confirmGalleryIntro(BuildContext context) {
return _confirmPermissionIntro(context, '相册权限', Constant.hasShownGalleryIntroKey);
} }
/// ///
...@@ -1175,7 +1289,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -1175,7 +1289,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
if (await _checkGalleryPermanentlyDenied()) { if (await _checkGalleryPermanentlyDenied()) {
var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'}; var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'};
_sendResponse(resp); _sendResponse(resp);
_permissionLead(context, '相册权限'); showPermissionSettingsDialog(context, '相册权限');
return;
}
// 首次使用相册前告知用户用途,用户暂不开启则直接返回
if (!await _confirmGalleryIntro(context)) {
var cancelResp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'cancel'};
_sendResponse(cancelResp);
return; return;
} }
...@@ -1222,7 +1343,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -1222,7 +1343,14 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
if (await _checkCameraPermanentlyDenied()) { if (await _checkCameraPermanentlyDenied()) {
var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'}; var resp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'no auth'};
_sendResponse(resp); _sendResponse(resp);
_permissionLead(context, '相机权限'); showPermissionSettingsDialog(context, '相机权限');
return;
}
// 首次使用相机前告知用户用途,用户暂不开启则直接返回
if (!await _confirmCameraIntro(context)) {
var cancelResp = {'unique': unique, 'cmd': cmd, 'data': null, 'errMsg': 'cancel'};
_sendResponse(cancelResp);
return; return;
} }
...@@ -1549,13 +1677,19 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver { ...@@ -1549,13 +1677,19 @@ class WebCubit extends Cubit<WebState> with WidgetsBindingObserver {
@override @override
Future<void> close() async { Future<void> close() async {
_isClosing = true;
final pending = _microphonePermissionCompleter;
if (pending != null) {
_completeMicrophonePermission(pending, 'cancel');
}
if (identical(WebCubitHolder.instance, this)) {
WebCubitHolder.unregister();
}
_server?.close(); _server?.close();
await _playerService?.close(); await _playerService?.close();
await _recorderService?.close(); await _recorderService?.close();
WebCubitHolder.unregister();
// 移除观察者 // 移除观察者
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
......
...@@ -124,6 +124,15 @@ class Constant { ...@@ -124,6 +124,15 @@ class Constant {
/// 首次打开登录页面时,是否已显示个人信息收集提示 /// 首次打开登录页面时,是否已显示个人信息收集提示
static const String hasShownPrivacyFirstTimeKey = 'has_shown_privacy_first_time'; static const String hasShownPrivacyFirstTimeKey = 'has_shown_privacy_first_time';
/// 首次使用相机前,是否已显示相机用途说明弹窗(仅在用户点击「继续」时写入)
static const String hasShownCameraIntroKey = 'has_shown_camera_intro';
/// 首次使用相册前,是否已显示相册用途说明弹窗(仅在用户点击「继续」时写入)
static const String hasShownGalleryIntroKey = 'has_shown_gallery_intro';
/// 首次使用麦克风前,是否已显示麦克风用途说明弹窗(仅在用户点击「继续」时写入)
static const String hasShownMicrophoneIntroKey = 'has_shown_microphone_intro';
/// 测试阶段使用 /// 测试阶段使用
static const bool needIM = false; static const bool needIM = false;
static const bool needUpgrade = true; static const bool needUpgrade = true;
......
...@@ -6,6 +6,7 @@ import 'package:appframe/data/models/message/h5_message.dart'; ...@@ -6,6 +6,7 @@ import 'package:appframe/data/models/message/h5_message.dart';
import 'package:appframe/data/models/message/h5_resp.dart'; import 'package:appframe/data/models/message/h5_resp.dart';
import 'package:appframe/data/repositories/message/upload_cancel_handler.dart'; import 'package:appframe/data/repositories/message/upload_cancel_handler.dart';
import 'package:appframe/data/repositories/message/upload_start_handler.dart'; import 'package:appframe/data/repositories/message/upload_start_handler.dart';
import 'package:flutter/cupertino.dart';
// 消息处理器抽象类 // 消息处理器抽象类
abstract class MessageHandler { abstract class MessageHandler {
...@@ -36,6 +37,8 @@ class MessageDispatcher { ...@@ -36,6 +37,8 @@ class MessageDispatcher {
final Map<String, dynamic> data = json.decode(message); final Map<String, dynamic> data = json.decode(message);
H5Message h5Message = H5Message.fromJson(data); H5Message h5Message = H5Message.fromJson(data);
debugPrint('Received Cmd: ${h5Message.cmd}');
var handler = _handlers[h5Message.cmd]; var handler = _handlers[h5Message.cmd];
if (handler == null) { if (handler == null) {
try { try {
......
import 'dart:async'; import 'dart:async';
import 'package:appframe/config/constant.dart'; import 'package:appframe/config/constant.dart';
import 'package:appframe/config/routes.dart';
import 'package:appframe/utils/audio_util.dart'; import 'package:appframe/utils/audio_util.dart';
import 'package:flutter_sound/flutter_sound.dart'; import 'package:flutter_sound/flutter_sound.dart';
import 'package:path/path.dart' as path; import 'package:path/path.dart' as path;
...@@ -14,8 +15,33 @@ class RecorderService { ...@@ -14,8 +15,33 @@ class RecorderService {
bool? _recorderIsInit; bool? _recorderIsInit;
String? _recordPath; String? _recordPath;
bool _isStarting = false;
///
/// 录音前的麦克风权限检查
///
/// 弹窗交由 WebCubit 处理,与相机、相册保持一致:
/// 首次使用先告知用途,权限被永久拒绝时引导用户去系统设置。
/// 返回 errMsg 时表示需要中断本次录音。
///
Future<void> _checkMicrophonePermission() async {
final cubit = WebCubitHolder.instance;
if (cubit == null || cubit.isClosed) {
throw RecordingPermissionException('cancel');
}
final errMsg = await cubit.checkMicrophonePermission();
if (errMsg != null) {
throw RecordingPermissionException(errMsg);
}
if (cubit.isClosed || !identical(WebCubitHolder.instance, cubit)) {
throw RecordingPermissionException('cancel');
}
}
Future<bool> _initRecorder(int maxDuration) async { Future<bool> _initRecorder(int maxDuration) async {
// 录音前先完成麦克风权限的告知与引导
await _checkMicrophonePermission();
// 请求麦克风权限 // 请求麦克风权限
var status = await Permission.microphone.request(); var status = await Permission.microphone.request();
if (status != PermissionStatus.granted) { if (status != PermissionStatus.granted) {
...@@ -53,7 +79,7 @@ class RecorderService { ...@@ -53,7 +79,7 @@ class RecorderService {
} }
Future<bool> startRecording(int maxDuration) async { Future<bool> startRecording(int maxDuration) async {
if (_recorderIsInit ?? false) { if (_isStarting || (_recorderIsInit ?? false)) {
return false; return false;
} }
...@@ -61,13 +87,19 @@ class RecorderService { ...@@ -61,13 +87,19 @@ class RecorderService {
return false; return false;
} }
final initResult = await _initRecorder(maxDuration); // 等待权限弹窗期间也视为启动中,避免并发请求重复打开录音器。
if (!initResult) { _isStarting = true;
return false; try {
} final initResult = await _initRecorder(maxDuration);
if (!initResult) {
return false;
}
await _recorder!.startRecorder(toFile: _recordPath, codec: Codec.aacMP4); await _recorder!.startRecorder(toFile: _recordPath, codec: Codec.aacMP4);
return true; return true;
} finally {
_isStarting = false;
}
} }
Future<bool> pauseRecording() async { Future<bool> pauseRecording() async {
......
...@@ -153,7 +153,9 @@ class WebPage extends StatelessWidget { ...@@ -153,7 +153,9 @@ class WebPage extends StatelessWidget {
); );
}, },
listener: (context, state) { listener: (context, state) {
if (state.suggestUpgrade) { if (state.microphonePermissionFlag) {
context.read<WebCubit>().handleMicrophonePermission(context);
} else if (state.suggestUpgrade) {
context.read<WebCubit>().suggestUpgrade(context); context.read<WebCubit>().suggestUpgrade(context);
} else if (state.orientationCmdFlag) { } else if (state.orientationCmdFlag) {
context.read<WebCubit>().getOrientation(context); context.read<WebCubit>().getOrientation(context);
......
import 'dart:math' as math;
import 'package:flutter/material.dart';
/// 弹窗标题中使用的应用名称
const String permissionDialogAppName = '班小二';
/// 去掉「权限」后缀,得到归一化后的权限名称(兼容「相机」与「相机权限」两种写法)
String normalizePermissionName(String permission) {
return permission.endsWith('权限') ? permission.substring(0, permission.length - 2) : permission;
}
/// 标题句式中与默认写法不同的权限(相册为「访问」,其余为「使用」)
const Map<String, String> _titleSuffixes = {
'相册': '需要访问您的相册',
};
/// 权限弹窗标题,例如:「班小二APP」需要使用相机、「班小二APP」需要访问您的相册
String permissionDialogTitle(String permission) {
final name = normalizePermissionName(permission);
final suffix = _titleSuffixes[name] ?? '需要使用$name';
return '"${permissionDialogAppName}APP"$suffix';
}
/// 权限类弹窗的纯样式骨架
///
/// 只负责渐变背景、盾牌插画、标题、说明以及主次按钮的排版与视觉,
/// 具体的文案与按钮行为由各业务组件通过参数传入,
/// 以便「首次使用权限说明」与「权限被拒后去设置引导」保持视觉统一。
class PermissionDialogShell extends StatelessWidget {
const PermissionDialogShell({
super.key,
required this.title,
required this.description,
required this.primaryText,
required this.onPrimary,
required this.secondaryText,
required this.onSecondary,
});
/// 弹窗标题,例如:「班小二APP」需要使用相机
final String title;
/// 弹窗说明文案
final String description;
/// 主按钮文案,例如:继续
final String primaryText;
/// 主按钮点击回调
final VoidCallback onPrimary;
/// 次按钮文案,例如:暂不开启
final String secondaryText;
/// 次按钮点击回调
final VoidCallback onSecondary;
/// 盾牌插画资源
static const String shieldAsset = 'assets/images/icon/shield.png';
@override
Widget build(BuildContext context) {
return Dialog(
backgroundColor: Colors.transparent,
elevation: 0,
insetPadding: const EdgeInsets.symmetric(horizontal: 40),
child: Container(
padding: const EdgeInsets.fromLTRB(22, 0, 22, 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20),
gradient: const LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Color(0xFFBAC8FC),
Color(0xFFFFFFFF),
],
stops: [0, 0.25],
transform: GradientRotation(20 * math.pi / 180),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// 插画
Center(
child: Image.asset(
shieldAsset,
height: 100,
fit: BoxFit.contain,
),
),
const SizedBox(height: 16),
// 标题
Text(
title,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w600,
height: 1.4,
color: Color(0xFF1A1A1A),
),
),
const SizedBox(height: 10),
// 说明
Text(
description,
textAlign: TextAlign.left,
style: const TextStyle(
fontSize: 15,
height: 1.6,
color: Color(0xFF333333),
),
),
const SizedBox(height: 22),
// 主按钮
ClipRRect(
borderRadius: BorderRadius.circular(23),
child: Material(
color: Colors.transparent,
child: Ink(
decoration: const BoxDecoration(
color: Color(0xFF7691FA),
),
child: InkWell(
onTap: onPrimary,
child: SizedBox(
height: 46,
child: Center(
child: Text(
primaryText,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
),
),
),
),
),
),
const SizedBox(height: 8),
// 次按钮
TextButton(
onPressed: onSecondary,
style: TextButton.styleFrom(
minimumSize: const Size.fromHeight(40),
foregroundColor: const Color(0xFF999999),
),
child: Text(
secondaryText,
style: const TextStyle(
fontSize: 16,
color: Color(0xFF999999),
),
),
),
],
),
),
);
}
}
import 'package:flutter/material.dart';
import 'permission_dialog_shell.dart';
/// 首次使用某权限前的用途说明弹窗
///
/// 返回 `true` 表示用户点击「继续」,可以继续进行权限申请与后续业务;
/// 返回 `false` 表示用户点击「暂不开启」或点击遮罩关闭。
class PermissionIntroDialog extends StatelessWidget {
const PermissionIntroDialog({super.key, required this.permission});
/// 权限名称,兼容「相机」与「相机权限」两种写法
final String permission;
/// 各权限的用途说明
static const Map<String, String> _descriptions = {
'相机': '为了方便您完成作业/打卡提交、纸本听写、作文、口算、错题上传、头像设置等学习任务,我们需要使用您的相机拍摄照片或视频。',
'相册': '为了方便您完成作业/打卡提交、纸本听写、作文、口算、错题上传、头像设置等学习任务,我们需要访问您的相册选择图片、视频上传。',
'麦克风': '为了方便您完成跟读、朗读、背诵等评测及在线音频作业录制,我们需要使用您的麦克风录制音频。',
};
static const String _fallbackDescription = '为了方便您正常使用相关功能,我们需要使用您的';
@override
Widget build(BuildContext context) {
final name = normalizePermissionName(permission);
return PermissionDialogShell(
title: permissionDialogTitle(permission),
description: _descriptions[name] ?? '$_fallbackDescription$name。',
primaryText: '继续',
onPrimary: () => Navigator.of(context).pop(true),
secondaryText: '暂不开启',
onSecondary: () => Navigator.of(context).pop(false),
);
}
}
/// 展示首次使用权限前的用途说明弹窗
///
/// 返回 `true` 表示用户同意继续。
Future<bool> showPermissionIntroDialog(BuildContext context, String permission) async {
final result = await showDialog<bool>(
context: context,
builder: (BuildContext context) => PermissionIntroDialog(permission: permission),
);
return result == true;
}
import 'package:app_settings/app_settings.dart';
import 'package:flutter/material.dart';
import 'permission_dialog_shell.dart';
/// 权限被永久拒绝后的「去设置」引导弹窗
///
/// 与首次使用的用途说明弹窗共用样式骨架,
/// 差异在于文案面向「已拒绝」场景,主按钮直接跳转系统应用设置页。
class PermissionSettingsDialog extends StatelessWidget {
const PermissionSettingsDialog({super.key, required this.permission});
/// 权限名称,兼容「相机」与「相机权限」两种写法
final String permission;
/// 各权限被拒绝后的引导说明
static const Map<String, String> _descriptions = {
'相机': '未开启相机权限,无法拍摄。您可以在"设置"中开启后重试。',
'相册': '未开启相册权限,无法选择图片。您可以在"设置"中开启后重试。',
'麦克风': '未开启麦克风权限,无法录音评测。您可以在"设置"中开启后重试。',
};
static const String _fallbackDescription = '未开启对应权限,无法使用相关功能。您可以在"设置"中开启后重试。';
@override
Widget build(BuildContext context) {
final name = normalizePermissionName(permission);
return PermissionDialogShell(
title: permissionDialogTitle(permission),
description: _descriptions[name] ?? _fallbackDescription,
primaryText: '去设置',
onPrimary: () {
Navigator.of(context).pop();
// 引导用户到系统应用设置页面手动开启权限
AppSettings.openAppSettings(asAnotherTask: true);
},
secondaryText: '取消',
onSecondary: () => Navigator.of(context).pop(),
);
}
}
/// 展示权限被永久拒绝后的「去设置」引导弹窗
Future<void> showPermissionSettingsDialog(BuildContext context, String permission) {
return showDialog<void>(
context: context,
builder: (BuildContext context) => PermissionSettingsDialog(permission: permission),
);
}
...@@ -112,6 +112,7 @@ flutter: ...@@ -112,6 +112,7 @@ flutter:
# 建议:不要直接引用 assets/ 根目录,这可能会导致打包进不必要的文件 # 建议:不要直接引用 assets/ 根目录,这可能会导致打包进不必要的文件
# 最好精确到子文件夹 # 最好精确到子文件夹
# - assets/dist.zip <-- 确认 zip 文件是否必须在运行时解压,这会增加包体积 # - assets/dist.zip <-- 确认 zip 文件是否必须在运行时解压,这会增加包体积
- assets/images/icon/
- assets/images/login/ - assets/images/login/
- assets/images/login_v2/ - assets/images/login_v2/
- assets/images/login_v3/ - assets/images/login_v3/
\ No newline at end of file \ No newline at end of file
Styling with Markdown is supported
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!