Skip to content
Toggle navigation
Toggle navigation
This project
Loading...
Sign in
ethan
/
appframe
Go to a project
Toggle navigation
Toggle navigation pinning
Projects
Groups
Snippets
Help
Project
Activity
Repository
Pipelines
Graphs
Issues
0
Merge Requests
0
Wiki
Network
Create a new issue
Jobs
Commits
Issue Boards
Files
Commits
Network
Compare
Branches
Tags
Commit ac90d982
authored
2026-10-29 16:22:48 +0800
by
tanghuan
Browse Files
Options
Browse Files
Tag
Download
Email Patches
Plain Diff
权限提示初版
1 parent
6426f18f
Hide whitespace changes
Inline
Side-by-side
Showing
10 changed files
with
486 additions
and
41 deletions
assets/images/icon/shield.png
lib/bloc/web_cubit.dart
lib/config/constant.dart
lib/services/dispatcher.dart
lib/services/recorder_service.dart
lib/ui/pages/web_page.dart
lib/ui/widgets/permission/permission_dialog_shell.dart
lib/ui/widgets/permission/permission_intro_dialog.dart
lib/ui/widgets/permission/permission_settings_dialog.dart
pubspec.yaml
assets/images/icon/shield.png
0 → 100644
View file @
ac90d98
22 KB
lib/bloc/web_cubit.dart
View file @
ac90d98
...
@@ -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
);
...
...
lib/config/constant.dart
View file @
ac90d98
...
@@ -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
;
...
...
lib/services/dispatcher.dart
View file @
ac90d98
...
@@ -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
{
...
...
lib/services/recorder_service.dart
View file @
ac90d98
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
{
...
...
lib/ui/pages/web_page.dart
View file @
ac90d98
...
@@ -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
);
...
...
lib/ui/widgets/permission/permission_dialog_shell.dart
0 → 100644
View file @
ac90d98
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
),
),
),
),
],
),
),
);
}
}
lib/ui/widgets/permission/permission_intro_dialog.dart
0 → 100644
View file @
ac90d98
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
;
}
lib/ui/widgets/permission/permission_settings_dialog.dart
0 → 100644
View file @
ac90d98
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
),
);
}
pubspec.yaml
View file @
ac90d98
...
@@ -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
Write
Preview
Styling with
Markdown
is supported
Attach a file
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to post a comment