Commit 819ae43b6372c182f6ab91a6313e226000746817

Authored by 吴启风
1 parent 081fbff7

feat:体验优化-练习题目取消阻塞,支持任何条件下的点击,增加体验流畅感

lib/common/utils/click_with_music_controller.dart
... ... @@ -11,6 +11,8 @@ class ClickWithMusicController {
11 11  
12 12 static ClickWithMusicController? _instance;
13 13  
  14 + static String TAG = 'ClickWithMusicController';
  15 +
14 16 ClickWithMusicController._privateConstructor();
15 17  
16 18 static ClickWithMusicController get instance => _instance ??= ClickWithMusicController._privateConstructor();
... ... @@ -21,11 +23,14 @@ class ClickWithMusicController {
21 23 ///@param action 可以是同步函数也可以是异步函数
22 24 Future<void> playMusicAndPerformAction(BuildContext? context,
23 25 AudioPlayerUtilType audioType, FutureOr<void> Function() action) async {
  26 + Log.d("$TAG playMusicAndPerformAction _isPlaying=$_isPlaying");
  27 + ///todo 是否需要考虑打断覆盖能力
24 28 if (_isPlaying) return;
25 29  
26 30 _isPlaying = true;
27   - Log.d("WQF playMusicAndPerformAction playAudio begin");
  31 + Log.d("$TAG playMusicAndPerformAction playAudio begin");
28 32  
  33 + await AudioPlayerUtil.getInstance().pause();
29 34 // Play the music
30 35 await AudioPlayerUtil.getInstance()
31 36 .playAudio(audioType);
... ... @@ -33,15 +38,16 @@ class ClickWithMusicController {
33 38 try {
34 39 await Future.sync(action);
35 40 } catch (e) {
36   - Log.d('WQF playMusicAndPerformAction exception $e');
  41 + Log.d('$TAG playMusicAndPerformAction exception $e');
37 42 } finally {
38   - Log.d("WQF playMusicAndPerformAction playAudio end");
  43 + Log.d("$TAG playMusicAndPerformAction playAudio end");
39 44 _isPlaying = false;
40 45 }
41 46 }
42 47  
43   - void reset() {
  48 + Future<void> reset() async {
44 49 _isPlaying = false;
  50 + await AudioPlayerUtil.getInstance().stop();
45 51 }
46 52  
47 53 // void dispose() {
... ...
lib/common/widgets/cheer_reward_widget.dart
... ... @@ -54,13 +54,13 @@ class _CheerRewardWidgetState extends State&lt;CheerRewardWidget&gt;
54 54 }
55 55  
56 56 void _startAnimation() {
57   - Log.d("$TAG _startAnimation");
  57 + Log.d("$TAG ${identityHashCode(this)} _startAnimation");
58 58 setState(() {
59 59 _isVisible = true;
60 60 });
61 61  
62 62 _futureComposition.then((composition) {
63   - Log.d("$TAG _futureComposition.then duration=${composition.duration}");
  63 + Log.d("$TAG ${identityHashCode(this)} _futureComposition.then duration=${composition.duration}");
64 64 _controller.duration = composition.duration;
65 65 _controller.forward().whenCompleteOrCancel(() {
66 66 if (mounted) {
... ...
lib/common/widgets/recorder_widget.dart
... ... @@ -14,7 +14,7 @@ class RecorderWidget extends StatefulWidget {
14 14  
15 15 const RecorderWidget({
16 16 Key? key,
17   - required this.isClickable,
  17 + this.isClickable = true,
18 18 required this.isPlaying,
19 19 required this.onTap,
20 20 required this.width,
... ...
lib/common/widgets/speaker_widget.dart
... ... @@ -52,7 +52,10 @@ class _SpeakerWidgetState extends State&lt;SpeakerWidget&gt;
52 52 Log.d(
53 53 "$TAG ${identityHashCode(this)} initState widget=${widget.isPlaying} _isPlaying=$_isPlaying _controller=${identityHashCode(_controller)}");
54 54 if (widget.isPlaying) {
55   - _startAnimation();
  55 + ///fixme 增加200毫秒延迟,避免一进入就已经开始播了,效果有待观察
  56 + Future.delayed(const Duration(milliseconds: 200), () {
  57 + _startAnimation();
  58 + });
56 59 }
57 60 }
58 61  
... ...
lib/models/voice_result_type.dart 0 → 100644
  1 +import 'package:wow_english/utils/audio_player_util.dart';
  2 +
  3 +/// 语音评测结果聚合类
  4 +class VoiceResultType {
  5 + /// lottie动画文件路径
  6 + final String? lottieFilePath;
  7 + /// 音效
  8 + final AudioPlayerUtilType audioType;
  9 + /// 得分范围最小值
  10 + final int minScore;
  11 + /// 得分范围最大值
  12 + final int maxScore;
  13 +
  14 + const VoiceResultType._(this.lottieFilePath, this.audioType, this.minScore, this.maxScore);
  15 +
  16 + static const VoiceResultType level1 = VoiceResultType._('assets/lotties/excellent.zip', AudioPlayerUtilType.excellent, 90, 100);
  17 + static const VoiceResultType level2 = VoiceResultType._('assets/lotties/great.zip', AudioPlayerUtilType.great, 70, 89);
  18 + static const VoiceResultType level3 = VoiceResultType._('assets/lotties/good.zip', AudioPlayerUtilType.good, 50, 69);
  19 + static const VoiceResultType level4 = VoiceResultType._(null, AudioPlayerUtilType.tryAgain, 0, 49);
  20 +
  21 + static List<VoiceResultType> get values => [level1, level2, level3, level4];
  22 +
  23 + static VoiceResultType fromScore(int score) {
  24 + return values.firstWhere(
  25 + (type) => score >= type.minScore && score <= type.maxScore,
  26 + orElse: () => level4, // 默认返回level4
  27 + );
  28 + }
  29 +}
0 30 \ No newline at end of file
... ...
lib/pages/practice/bloc/topic_picture_bloc.dart
... ... @@ -20,8 +20,8 @@ import &#39;package:wow_english/utils/toast_util.dart&#39;;
20 20 import '../../../common/permission/permissionRequester.dart';
21 21 import '../../../common/utils/click_with_music_controller.dart';
22 22 import '../../../common/utils/show_star_reward_dialog.dart';
  23 +import '../../../models/voice_result_type.dart';
23 24 import '../../../route/route.dart';
24   -import '../../../utils/log_util.dart';
25 25  
26 26 part 'topic_picture_event.dart';
27 27  
... ... @@ -49,7 +49,7 @@ class TopicPictureBloc
49 49  
50 50 int _currentPage = 0;
51 51  
52   - int _selectItem = -1;
  52 + int _optionSelectItem = -1;
53 53  
54 54 CourseProcessEntity? _entity;
55 55  
... ... @@ -61,19 +61,10 @@ class TopicPictureBloc
61 61 ///正在播放音频
62 62 VoicePlayState _voicePlayState = VoicePlayState.unKnow;
63 63  
64   - // 是否是回答(选择)结果音效
65   - bool _isResultSoundPlaying = false;
66   -
67   - bool get isResultSoundPlaying => _isResultSoundPlaying;
68   -
69   - // 答对播放音效时禁止任何点击(选择)操作
70   - bool _forbiddenWhenCorrect = false;
71   -
72   - bool get forbiddenWhenCorrect => _forbiddenWhenCorrect;
73   -
74 64 int get currentPage => _currentPage + 1;
75 65  
76   - int get selectItem => _selectItem;
  66 + /// 选择题选中项
  67 + int get optionSelectItem => _optionSelectItem;
77 68  
78 69 bool get isRecording => _isRecording;
79 70  
... ... @@ -103,46 +94,24 @@ class TopicPictureBloc
103 94 //音频播放器
104 95 audioPlayer = AudioPlayer();
105 96 audioPlayer.onPlayerStateChanged.listen((event) async {
106   - debugPrint(
107   - '播放状态变化 _voicePlayState=$_voicePlayState event=$event _isResultSoundPlaying=$_isResultSoundPlaying _forbiddenWhenCorrect=$_forbiddenWhenCorrect');
108   - if (_isResultSoundPlaying) {
109   - if (event != PlayerState.playing) {
110   - _isResultSoundPlaying = false;
111   - if (_forbiddenWhenCorrect) {
112   - _forbiddenWhenCorrect = false;
113   - debugPrint('播放完成后解除禁止');
114   - if (event == PlayerState.completed) {
115   - if (isLastPage()) {
116   - showStepPage();
117   - } else {
118   - // 答对后且播放完自动翻页
119   - pageController.nextPage(
120   - duration: const Duration(milliseconds: 250),
121   - curve: Curves.ease,
122   - );
123   - }
124   - }
125   - }
126   - }
127   - } else {
128   - if (event == PlayerState.completed) {
129   - debugPrint('播放完成');
130   - _voicePlayState = VoicePlayState.completed;
131   - }
132   - if (event == PlayerState.stopped) {
133   - debugPrint('播放结束');
134   - _voicePlayState = VoicePlayState.stop;
135   - }
  97 + debugPrint('播放状态变化 _voicePlayState=$_voicePlayState event=$event');
  98 + if (event == PlayerState.completed) {
  99 + debugPrint('播放完成');
  100 + _voicePlayState = VoicePlayState.completed;
  101 + }
  102 + if (event == PlayerState.stopped) {
  103 + debugPrint('播放结束');
  104 + _voicePlayState = VoicePlayState.stop;
  105 + }
136 106  
137   - if (event == PlayerState.playing) {
138   - debugPrint('正在播放中');
139   - _voicePlayState = VoicePlayState.playing;
140   - }
141   - if (isClosed) {
142   - return;
143   - }
144   - add(VoicePlayStateChangeEvent());
  107 + if (event == PlayerState.playing) {
  108 + debugPrint('正在播放中');
  109 + _voicePlayState = VoicePlayState.playing;
145 110 }
  111 + if (isClosed) {
  112 + return;
  113 + }
  114 + add(VoicePlayStateChangeEvent());
146 115 });
147 116  
148 117 methodChannel =
... ... @@ -191,8 +160,6 @@ class TopicPictureBloc
191 160 pageController.dispose();
192 161 audioPlayer.release();
193 162 audioPlayer.dispose();
194   - _isResultSoundPlaying = false;
195   - _forbiddenWhenCorrect = false;
196 163 _voiceXsCancel();
197 164 return super.close();
198 165 }
... ... @@ -213,7 +180,7 @@ class TopicPictureBloc
213 180 ///页面切换
214 181 void _pageControllerChange(CurrentPageIndexChangeEvent event,
215 182 Emitter<TopicPictureState> emitter) async {
216   - await closePlayerResource();
  183 + await pageResetIfNeed();
217 184 debugPrint('翻页 $_currentPage->${event.pageIndex}');
218 185 if (_currentPage == _entity?.topics?.length) {
219 186 return;
... ... @@ -229,26 +196,22 @@ class TopicPictureBloc
229 196 }
230 197 }
231 198 }
232   - _selectItem = -1;
233 199 emitter(CurrentPageIndexState());
234 200 }
235 201  
236 202 ///选择
237 203 void _selectItemLoad(
238 204 SelectItemEvent event, Emitter<TopicPictureState> emitter) async {
239   - if (_forbiddenWhenCorrect) {
240   - return;
241   - }
242   - _selectItem = event.selectIndex;
243   - if (checkAnswerRight(_selectItem) == true) {
244   - _playResultSound(true);
  205 + _optionSelectItem = event.selectIndex;
  206 + emitter(SelectItemChangeState());
  207 + if (checkAnswerRight(_optionSelectItem) == true) {
  208 + /// 如果选择题答(选)对后题目没播完,则暂停播放题目。答错的话继续播放体验也不错
  209 + await closePlayerResource();
245 210 showStarRewardDialog(context);
246   - // showToast('恭喜你,答对啦!',duration: const Duration(seconds: 2));
  211 + await _playResultSound(true);
247 212 } else {
248   - _playResultSound(false);
249   - // showToast('继续加油哦',duration: const Duration(seconds: 2));
  213 + await _playResultSound(false);
250 214 }
251   - emitter(SelectItemChangeState());
252 215 }
253 216  
254 217 ///为空则数据异常,用于是否晃动时需要
... ... @@ -290,69 +253,37 @@ class TopicPictureBloc
290 253 }
291 254  
292 255 ///终止评测
293   - void _voiceXsStop(
  256 + Future<void> _voiceXsStop(
294 257 XSVoiceStopEvent event, Emitter<TopicPictureState> emitter) async {
295 258 methodChannel.invokeMethod('stopVoice');
296 259 }
297 260  
298 261 ///取消评测(用于处理退出页面后录音未停止等异常情况的保护操作)
299   - void _voiceXsCancel() {
300   - methodChannel.invokeMethod('cancelVoice');
  262 + Future<void> _voiceXsCancel({bool force = false}) async {
  263 + if (_isRecording || force) {
  264 + methodChannel.invokeMethod('cancelVoice');
  265 + }
301 266 }
302 267  
303 268 ///先声评测结果
304 269 void _voiceXsResult(
305 270 XSVoiceResultEvent event, Emitter<TopicPictureState> emitter) async {
  271 + _isRecording = false;
  272 + emitter(XSVoiceTestState());
306 273 final Map args = event.message as Map;
307 274 final result = args['result'] as Map;
308 275 final overall = result['overall'].toString();
309 276 int score = int.parse(overall);
310   - AudioPlayerUtilType audioPlayerUtilType;
311   - String? lottieFile;
312   - if (score > 90) {
313   - audioPlayerUtilType = AudioPlayerUtilType.excellent;
314   - lottieFile = 'assets/lotties/excellent.zip';
315   - } else if (score > 70) {
316   - audioPlayerUtilType = AudioPlayerUtilType.great;
317   - lottieFile = 'assets/lotties/great.zip';
318   - } else if (score > 50) {
319   - audioPlayerUtilType = AudioPlayerUtilType.good;
320   - lottieFile = 'assets/lotties/good.zip';
321   - } else {
322   - audioPlayerUtilType = AudioPlayerUtilType.tryAgain;
323   - }
324   - if (lottieFile != null) {
325   - showCheerRewardDialog(context, lottieFile: lottieFile);
326   - }
327   - Log.d("WQF audioPlayerUtilType=$audioPlayerUtilType lottieFile=$lottieFile");
328   - ClickWithMusicController.instance
329   - .playMusicAndPerformAction(context, audioPlayerUtilType, () => {
330   - ///todo 是否需要自动翻页
331   - });
332   - // showToast('测评成功,分数是$overall', duration: const Duration(seconds: 5));
333   - _isRecording = false;
334   - emitter(XSVoiceTestState());
335   - if (isLastPage()) {
336   - showStepPage();
337   - }
338   - }
339   -
340   - /// 根据得分计算星星数
341   - int _evaluateScore(String scoreStr) {
342   - try {
343   - int score = int.parse(scoreStr);
344   - if (score > 80) {
345   - return 3;
346   - } else if (score > 60) {
347   - return 2;
348   - } else {
349   - return 1;
350   - }
351   - } catch (e) {
352   - // 如果转换失败,可以返回一个默认值或抛出异常
353   - print('Error parsing score: $e');
354   - return 1; // 返回一个默认值表示错误
  277 + final voiceResult = VoiceResultType.fromScore(score);
  278 + if (voiceResult.lottieFilePath != null) {
  279 + showCheerRewardDialog(context, lottieFile: voiceResult.lottieFilePath!);
355 280 }
  281 + await ClickWithMusicController.instance.playMusicAndPerformAction(
  282 + context,
  283 + voiceResult.audioType,
  284 + () {
  285 + if (isLastPage()) {showStepPage();};
  286 + });
356 287 }
357 288  
358 289 // 暂时没用上
... ... @@ -364,38 +295,46 @@ class TopicPictureBloc
364 295 // 题目音频播放
365 296 void _questionVoicePlay(
366 297 VoicePlayEvent event, Emitter<TopicPictureState> emitter) async {
367   - if (_forbiddenWhenCorrect) {
368   - return;
369   - }
370   - _forbiddenWhenCorrect = false;
371   - await closePlayerResource();
  298 + await pageResetIfNeed();
372 299 final topics = _entity?.topics?[_currentPage];
373 300 final urlStr = topics?.audioUrl ?? '';
374 301 await audioPlayer.play(UrlSource(urlStr),
375 302 balance: 0.0, ctx: AudioContext());
376 303 }
377 304  
  305 + /// 重置状态,音频播放、录音以及一些变量等。用于翻页,打断等场景
  306 + Future<void> pageResetIfNeed() async {
  307 + _optionSelectItem = -1;
  308 + _isRecording = false;
  309 + _voicePlayState = VoicePlayState.stop;
  310 +
  311 + await closePlayerResource();
  312 + await _voiceXsCancel();
  313 + }
  314 +
378 315 Future<void> closePlayerResource() async {
379   - if (voicePlayState == VoicePlayState.playing || _isResultSoundPlaying) {
  316 + if (voicePlayState == VoicePlayState.playing) {
380 317 await audioPlayer.stop();
381 318 }
  319 + await ClickWithMusicController.instance.reset();
382 320 }
383 321  
384 322 ///播放选择结果音效
385   - void _playResultSound(bool isCorrect) async {
386   - // await audioPlayer.stop();
387   - if (audioPlayer.state == PlayerState.playing &&
388   - _isResultSoundPlaying == false) {
389   - _voicePlayState = VoicePlayState.stop;
390   - }
391   - debugPrint("_playResultSound isCorrect=$isCorrect");
392   - _isResultSoundPlaying = true;
393   - _forbiddenWhenCorrect = isCorrect;
394   - if (isCorrect) {
395   - await audioPlayer.play(AssetSource('right'.assetMp3));
396   - } else {
397   - await audioPlayer.play(AssetSource('wrong'.assetMp3));
398   - }
  323 + Future<void> _playResultSound(bool isCorrect) async {
  324 + await ClickWithMusicController.instance.playMusicAndPerformAction(context,
  325 + isCorrect ? AudioPlayerUtilType.right : AudioPlayerUtilType.wrong, () {
  326 + if (isCorrect) {
  327 + if (isLastPage()) {
  328 + showStepPage();
  329 + } else {
  330 + // 答对后且播放完自动翻页
  331 + pageController.nextPage(
  332 + duration: const Duration(milliseconds: 250),
  333 + curve: Curves.ease,
  334 + );
  335 + }
  336 + }
  337 + });
399 338 }
400 339  
401 340 ///是否是最后一页
... ...
lib/pages/practice/topic_picture_page.dart
... ... @@ -173,7 +173,7 @@ class _TopicPicturePage extends StatelessWidget {
173 173 buildWhen: (_, s) => s is SelectItemChangeState,
174 174 builder: (context, state) {
175 175 final bloc = BlocProvider.of<TopicPictureBloc>(context);
176   - final isAnswerOption = bloc.selectItem == index;
  176 + final isAnswerOption = bloc.optionSelectItem == index;
177 177 final answerCorrect =
178 178 isAnswerOption && bloc.checkAnswerRight(index) == true;
179 179 return Container(
... ... @@ -243,7 +243,7 @@ class _TopicPicturePage extends StatelessWidget {
243 243 buildWhen: (_, s) => s is SelectItemChangeState,
244 244 builder: (context, state) {
245 245 final bloc = BlocProvider.of<TopicPictureBloc>(context);
246   - final isAnswerOption = bloc.selectItem == index;
  246 + final isAnswerOption = bloc.optionSelectItem == index;
247 247 final answerCorrect =
248 248 isAnswerOption && bloc.checkAnswerRight(index) == true;
249 249 return Container(
... ... @@ -349,7 +349,7 @@ class _TopicPicturePage extends StatelessWidget {
349 349 buildWhen: (_, s) => s is SelectItemChangeState,
350 350 builder: (context, state) {
351 351 final bloc = BlocProvider.of<TopicPictureBloc>(context);
352   - final isAnswerOption = bloc.selectItem == index;
  352 + final isAnswerOption = bloc.optionSelectItem == index;
353 353 final answerCorrect =
354 354 isAnswerOption && bloc.checkAnswerRight(index) == true;
355 355 return OptionWidget(
... ... @@ -423,7 +423,7 @@ class _TopicPicturePage extends StatelessWidget {
423 423 buildWhen: (_, s) => s is SelectItemChangeState,
424 424 builder: (context, state) {
425 425 final bloc = BlocProvider.of<TopicPictureBloc>(context);
426   - final isAnswerOption = bloc.selectItem == index;
  426 + final isAnswerOption = bloc.optionSelectItem == index;
427 427 final answerCorrect =
428 428 isAnswerOption && bloc.checkAnswerRight(index) == true;
429 429 return OptionWidget(
... ... @@ -522,8 +522,6 @@ class _TopicPicturePage extends StatelessWidget {
522 522 70.verticalSpace,
523 523 RecorderWidget(
524 524 isPlaying: bloc.isRecording,
525   - isClickable:
526   - bloc.voicePlayState != VoicePlayState.playing,
527 525 width: 72.w,
528 526 height: 72.w,
529 527 onTap: () {
... ...
lib/utils/audio_player_util.dart
... ... @@ -18,7 +18,9 @@ enum AudioPlayerUtilType {
18 18 excellent('excellent'),
19 19 great('great'),
20 20 good('good'),
21   - tryAgain('try_again');
  21 + tryAgain('try_again'),
  22 + right('right'),
  23 + wrong('wrong');
22 24  
23 25 const AudioPlayerUtilType(this.path);
24 26  
... ...