Commit d4d91cb0f04764f3a01d6d8138ae3a24e4ba8410

Authored by 吴启风
1 parent aefec95d

feat:lottie动效组件封装&语音跟读页动效

lib/common/widgets/record_playback_widget.dart 0 → 100644
  1 +import 'package:flutter/material.dart';
  2 +import 'package:lottie/lottie.dart';
  3 +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart';
  4 +
  5 +import '../../utils/log_util.dart';
  6 +
  7 +/// 录音组件
  8 +class _RecorderPlaybackWidget extends StatefulWidget {
  9 + final bool isClickable;
  10 + final bool isPlaying;
  11 + final VoidCallback? onTap;
  12 + final double width;
  13 + final double height;
  14 +
  15 + const _RecorderPlaybackWidget({
  16 + Key? key,
  17 + required this.isClickable,
  18 + required this.isPlaying,
  19 + required this.onTap,
  20 + required this.width,
  21 + required this.height,
  22 + }) : super(key: key);
  23 +
  24 + @override
  25 + __RecorderPlaybackWidgetState createState() =>
  26 + __RecorderPlaybackWidgetState();
  27 +}
  28 +
  29 +class __RecorderPlaybackWidgetState extends State<_RecorderPlaybackWidget>
  30 + with SingleTickerProviderStateMixin {
  31 + late final AnimationController _controller;
  32 + late final LottieComposition _composition;
  33 + static const String TAG = "_RecorderPlaybackWidget";
  34 +
  35 + bool _isPlaying = false;
  36 +
  37 + @override
  38 + void initState() {
  39 + super.initState();
  40 + _controller = AnimationController(vsync: this);
  41 + _loadComposition();
  42 +
  43 + _controller.addListener(() {
  44 + // Log.d("$TAG addListener _controller=${_controller.status}");
  45 + });
  46 + }
  47 +
  48 + Future<void> _loadComposition() async {
  49 + final composition =
  50 + await AssetLottie('assets/lotties/recorder_back.zip').load();
  51 + setState(() {
  52 + _composition = composition;
  53 + _controller.duration = _composition.duration;
  54 + });
  55 +
  56 + if (widget.isPlaying) {
  57 + _playAnimation();
  58 + }
  59 + }
  60 +
  61 + void _playAnimation() {
  62 + _controller.reset();
  63 + _controller.repeat(
  64 + min: 2 / _composition.endFrame,
  65 + max: 22 / _composition.endFrame,
  66 + );
  67 + }
  68 +
  69 + void _stopAnimation() {
  70 + _controller.stop();
  71 + _resetAnimation();
  72 + }
  73 +
  74 + void _resetAnimation() {
  75 + setState(() {
  76 + _isPlaying = false;
  77 + _controller.value = 1;
  78 + // _controller.repeat(
  79 + // min: 1 / _composition.endFrame,
  80 + // max: 1 / _composition.endFrame,
  81 + // );
  82 + });
  83 + }
  84 +
  85 + void _displayAnimation(bool clickable) {
  86 + if (clickable) {
  87 + _controller.value = 1;
  88 + // _controller.repeat(
  89 + // min: 1,
  90 + // max: 1,
  91 + // );
  92 + } else {
  93 + _controller.value = 0;
  94 + // _controller.repeat(
  95 + // min: 0,
  96 + // max: 0,
  97 + // );
  98 + }
  99 + }
  100 +
  101 + @override
  102 + void didUpdateWidget(_RecorderPlaybackWidget oldWidget) {
  103 + super.didUpdateWidget(oldWidget);
  104 + Log.d(
  105 + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying");
  106 + if (widget.isPlaying && !_isPlaying) {
  107 + setState(() {
  108 + _isPlaying = true;
  109 + });
  110 + _playAnimation();
  111 + } else if (!widget.isPlaying && _isPlaying) {
  112 + _stopAnimation();
  113 + }
  114 +
  115 + if (!_isPlaying) {
  116 + _displayAnimation(widget.isClickable);
  117 + }
  118 + }
  119 +
  120 + @override
  121 + void dispose() {
  122 + _controller.dispose();
  123 + super.dispose();
  124 + }
  125 +
  126 + @override
  127 + Widget build(BuildContext context) {
  128 + return ThrottledGestureDetector(
  129 + onTap: widget.isClickable ? widget.onTap : null,
  130 + child: Opacity(
  131 + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度
  132 + child: Lottie(
  133 + composition: _composition,
  134 + controller: _controller,
  135 + renderCache: RenderCache.raster,
  136 + width: widget.width,
  137 + height: widget.height,
  138 + ),
  139 + ),
  140 + );
  141 + }
  142 +}
... ...
lib/common/widgets/recorder_widget.dart 0 → 100644
  1 +import 'package:flutter/material.dart';
  2 +import 'package:lottie/lottie.dart';
  3 +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart';
  4 +
  5 +import '../../utils/log_util.dart';
  6 +
  7 +/// 录音组件
  8 +class RecorderWidget extends StatefulWidget {
  9 + final bool isClickable;
  10 + final bool isPlaying;
  11 + final VoidCallback? onTap;
  12 + final double width;
  13 + final double height;
  14 +
  15 + const RecorderWidget({
  16 + Key? key,
  17 + required this.isClickable,
  18 + required this.isPlaying,
  19 + required this.onTap,
  20 + required this.width,
  21 + required this.height,
  22 + }) : super(key: key);
  23 +
  24 + @override
  25 + _RecorderWidgetState createState() => _RecorderWidgetState();
  26 +}
  27 +
  28 +class _RecorderWidgetState extends State<RecorderWidget>
  29 + with SingleTickerProviderStateMixin {
  30 + late final AnimationController _controller;
  31 + late final LottieComposition _composition;
  32 + static const String TAG = "RecorderWidget";
  33 +
  34 + bool _isPlaying = false;
  35 +
  36 + @override
  37 + void initState() {
  38 + super.initState();
  39 + _controller = AnimationController(vsync: this);
  40 + _loadComposition();
  41 +
  42 + _controller.addListener(() {
  43 + // Log.d("$TAG addListener _controller=${_controller.status}");
  44 + });
  45 + }
  46 +
  47 + Future<void> _loadComposition() async {
  48 + final composition = await AssetLottie('assets/lotties/recorder_input.zip').load();
  49 + setState(() {
  50 + _composition = composition;
  51 + _controller.duration = _composition.duration;
  52 + });
  53 +
  54 + if (widget.isPlaying) {
  55 + _playInitialAnimation();
  56 + }
  57 + }
  58 +
  59 + void _playInitialAnimation() {
  60 + _controller.reset();
  61 + _controller
  62 + .animateTo(22 / _composition.endFrame)
  63 + .whenComplete(() => _loopMiddleAnimation());
  64 + }
  65 +
  66 + void _loopMiddleAnimation() {
  67 + _controller.repeat(
  68 + min: 22 / _composition.endFrame,
  69 + max: 37 / _composition.endFrame,
  70 + );
  71 + }
  72 +
  73 + void _playFinalAnimation() {
  74 + _controller.stop();
  75 + _controller
  76 + .animateTo(50 / _composition.endFrame)
  77 + .whenComplete(() => _resetAnimation());
  78 + }
  79 +
  80 + void _resetAnimation() {
  81 + setState(() {
  82 + _isPlaying = false;
  83 + _controller.value = 0;
  84 + });
  85 + }
  86 +
  87 + @override
  88 + void didUpdateWidget(RecorderWidget oldWidget) {
  89 + super.didUpdateWidget(oldWidget);
  90 + Log.d("$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying");
  91 + if (widget.isPlaying && !_isPlaying) {
  92 + setState(() {
  93 + _isPlaying = true;
  94 + });
  95 + _playInitialAnimation();
  96 + } else if (!widget.isPlaying && _isPlaying) {
  97 + _playFinalAnimation();
  98 + }
  99 + }
  100 +
  101 + @override
  102 + void dispose() {
  103 + _controller.dispose();
  104 + super.dispose();
  105 + }
  106 +
  107 + @override
  108 + Widget build(BuildContext context) {
  109 + return ThrottledGestureDetector(
  110 + onTap: widget.isClickable ? widget.onTap : null,
  111 + child: Opacity(
  112 + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度
  113 + child: Lottie(
  114 + composition: _composition,
  115 + controller: _controller,
  116 + renderCache: RenderCache.raster,
  117 + width: widget.width,
  118 + height: widget.height,
  119 + ),
  120 + ),
  121 + );
  122 + }
  123 +}
... ...
lib/common/widgets/speaker_widget.dart
... ... @@ -2,6 +2,9 @@ import &#39;package:flutter/material.dart&#39;;
2 2 import 'dart:async';
3 3  
4 4 import 'package:wow_english/common/extension/string_extension.dart';
  5 +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart';
  6 +
  7 +import '../../utils/log_util.dart';
5 8  
6 9 /// 扬声器播放组件,帧动画
7 10 class SpeakerWidget extends StatefulWidget {
... ... @@ -34,6 +37,9 @@ class _SpeakerWidgetState extends State&lt;SpeakerWidget&gt;
34 37 'ic_speaker_play0'.assetPng,
35 38 'ic_speaker_play1'.assetPng,
36 39 ];
  40 + static const String TAG = "SpeakerWidget";
  41 +
  42 + bool _isPlaying = false;
37 43  
38 44 @override
39 45 void initState() {
... ... @@ -51,12 +57,15 @@ class _SpeakerWidgetState extends State&lt;SpeakerWidget&gt;
51 57 @override
52 58 void didUpdateWidget(SpeakerWidget oldWidget) {
53 59 super.didUpdateWidget(oldWidget);
54   - if (widget.isPlaying != oldWidget.isPlaying) {
55   - if (widget.isPlaying) {
56   - _startAnimation();
57   - } else {
58   - _stopAnimation();
59   - }
  60 + Log.d(
  61 + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying");
  62 + if (widget.isPlaying && !_isPlaying) {
  63 + setState(() {
  64 + _isPlaying = true;
  65 + });
  66 + _startAnimation();
  67 + } else if (!widget.isPlaying && _isPlaying) {
  68 + _stopAnimation();
60 69 }
61 70 }
62 71  
... ... @@ -71,6 +80,7 @@ class _SpeakerWidgetState extends State&lt;SpeakerWidget&gt;
71 80 void _stopAnimation() {
72 81 _timer?.cancel();
73 82 setState(() {
  83 + _isPlaying = false;
74 84 _currentFrame = 0;
75 85 });
76 86 }
... ... @@ -84,13 +94,19 @@ class _SpeakerWidgetState extends State&lt;SpeakerWidget&gt;
84 94  
85 95 @override
86 96 Widget build(BuildContext context) {
87   - return GestureDetector(
  97 + return ThrottledGestureDetector(
88 98 onTap: widget.isClickable ? widget.onTap : null,
89   - child: Image.asset(
90   - _speakerImagePaths[_currentFrame],
91   - width: widget.width,
92   - height: widget.height,
93   - fit: BoxFit.cover,
  99 + child: Opacity(
  100 + opacity: widget.isClickable ? 1.0 : 0.5,
  101 + child: Image.asset(
  102 + _speakerImagePaths[_currentFrame],
  103 + width: widget.width,
  104 + height: widget.height,
  105 + fit: BoxFit.cover,
  106 + color: widget.isClickable ? null : Colors.transparent,
  107 + // 灰色遮罩
  108 + colorBlendMode: widget.isClickable ? null : BlendMode.saturation,
  109 + ),
94 110 ),
95 111 );
96 112 }
... ...
lib/common/widgets/throttledGesture_gesture_detector.dart
... ... @@ -4,7 +4,7 @@ import &#39;package:flutter/material.dart&#39;;
4 4 ///带节流功能的GestureDetector
5 5 class ThrottledGestureDetector extends StatefulWidget {
6 6 final Widget child;
7   - final VoidCallback onTap;
  7 + final VoidCallback? onTap;
8 8 final int throttleTime;
9 9  
10 10 const ThrottledGestureDetector({
... ... @@ -24,7 +24,9 @@ class _ThrottledGestureDetectorState extends State&lt;ThrottledGestureDetector&gt; {
24 24  
25 25 void _handleTap() {
26 26 if (!_isThrottled) {
27   - widget.onTap();
  27 + if (widget.onTap != null) {
  28 + widget.onTap!();
  29 + }
28 30 _isThrottled = true;
29 31 Timer(Duration(milliseconds: widget.throttleTime), () {
30 32 _isThrottled = false;
... ...
lib/pages/practice/bloc/topic_picture_bloc.dart
... ... @@ -53,7 +53,7 @@ class TopicPictureBloc
53 53 CourseProcessEntity? get entity => _entity;
54 54  
55 55 ///正在评测
56   - bool _isVoicing = false;
  56 + bool _isRecording = false;
57 57  
58 58 ///正在播放音频
59 59 VoicePlayState _voicePlayState = VoicePlayState.unKnow;
... ... @@ -72,7 +72,7 @@ class TopicPictureBloc
72 72  
73 73 int get selectItem => _selectItem;
74 74  
75   - bool get isVoicing => _isVoicing;
  75 + bool get isRecording => _isRecording;
76 76  
77 77 VoicePlayState get voicePlayState => _voicePlayState;
78 78  
... ... @@ -277,7 +277,7 @@ class TopicPictureBloc
277 277 'type': event.type,
278 278 'userId': event.userId.toString()
279 279 });
280   - _isVoicing = true;
  280 + _isRecording = true;
281 281 emitter(XSVoiceTestState());
282 282 }
283 283 }
... ... @@ -300,7 +300,7 @@ class TopicPictureBloc
300 300 final result = args['result'] as Map;
301 301 final overall = result['overall'].toString();
302 302 showToast('测评成功,分数是$overall', duration: const Duration(seconds: 5));
303   - _isVoicing = false;
  303 + _isRecording = false;
304 304 emitter(XSVoiceTestState());
305 305 if (isLastPage()) {
306 306 showStepPage();
... ...
lib/pages/practice/topic_picture_page.dart
... ... @@ -11,9 +11,8 @@ import &#39;package:wow_english/pages/practice/widgets/shake_widget.dart&#39;;
11 11 import 'package:wow_english/route/route.dart';
12 12 import 'package:wow_english/utils/toast_util.dart';
13 13  
  14 +import '../../common/widgets/recorder_widget.dart';
14 15 import '../../common/widgets/speaker_widget.dart';
15   -import '../../common/widgets/throttledGesture_gesture_detector.dart';
16   -import '../../utils/log_util.dart';
17 16 import 'bloc/topic_picture_bloc.dart';
18 17 import 'widgets/practice_header_widget.dart';
19 18  
... ... @@ -167,10 +166,10 @@ class _TopicPicturePage extends StatelessWidget {
167 166 builder: (context, state) {
168 167 final bloc = BlocProvider.of<TopicPictureBloc>(context);
169 168 final isAnswerOption = bloc.selectItem == index;
170   - final answerCorrect = isAnswerOption &&
171   - bloc.checkAnswerRight(index) == true;
172   - final answerIncorrect = isAnswerOption &&
173   - bloc.checkAnswerRight(index) == false;
  169 + final answerCorrect =
  170 + isAnswerOption && bloc.checkAnswerRight(index) == true;
  171 + final answerIncorrect =
  172 + isAnswerOption && bloc.checkAnswerRight(index) == false;
174 173 return Container(
175 174 padding: EdgeInsets.symmetric(horizontal: 10.w),
176 175 child: GestureDetector(
... ... @@ -239,10 +238,10 @@ class _TopicPicturePage extends StatelessWidget {
239 238 builder: (context, state) {
240 239 final bloc = BlocProvider.of<TopicPictureBloc>(context);
241 240 final isAnswerOption = bloc.selectItem == index;
242   - final answerCorrect = isAnswerOption &&
243   - bloc.checkAnswerRight(index) == true;
244   - final answerIncorrect = isAnswerOption &&
245   - bloc.checkAnswerRight(index) == false;
  241 + final answerCorrect =
  242 + isAnswerOption && bloc.checkAnswerRight(index) == true;
  243 + final answerIncorrect =
  244 + isAnswerOption && bloc.checkAnswerRight(index) == false;
246 245 return Container(
247 246 padding: EdgeInsets.symmetric(horizontal: 10.w),
248 247 child: GestureDetector(
... ... @@ -348,10 +347,10 @@ class _TopicPicturePage extends StatelessWidget {
348 347 builder: (context, state) {
349 348 final bloc = BlocProvider.of<TopicPictureBloc>(context);
350 349 final isAnswerOption = bloc.selectItem == index;
351   - final answerCorrect = isAnswerOption &&
352   - bloc.checkAnswerRight(index) == true;
353   - final answerIncorrect = isAnswerOption &&
354   - bloc.checkAnswerRight(index) == false;
  350 + final answerCorrect =
  351 + isAnswerOption && bloc.checkAnswerRight(index) == true;
  352 + final answerIncorrect =
  353 + isAnswerOption && bloc.checkAnswerRight(index) == false;
355 354 return ShakeWidget(
356 355 shouldShake: answerIncorrect,
357 356 child: Container(
... ... @@ -425,10 +424,10 @@ class _TopicPicturePage extends StatelessWidget {
425 424 builder: (context, state) {
426 425 final bloc = BlocProvider.of<TopicPictureBloc>(context);
427 426 final isAnswerOption = bloc.selectItem == index;
428   - final answerCorrect = isAnswerOption &&
429   - bloc.checkAnswerRight(index) == true;
430   - final answerIncorrect = isAnswerOption &&
431   - bloc.checkAnswerRight(index) == false;
  427 + final answerCorrect =
  428 + isAnswerOption && bloc.checkAnswerRight(index) == true;
  429 + final answerIncorrect =
  430 + isAnswerOption && bloc.checkAnswerRight(index) == false;
432 431 return GestureDetector(
433 432 onTap: () => bloc.add(SelectItemEvent(index)),
434 433 child: ShakeWidget(
... ... @@ -505,40 +504,31 @@ class _TopicPicturePage extends StatelessWidget {
505 504 Column(
506 505 mainAxisAlignment: MainAxisAlignment.center,
507 506 children: [
508   - GestureDetector(
509   - onTap: () {
510   - if (bloc.isVoicing) {
511   - showToast('正在录音,不能终止');
512   - return;
513   - }
514   - bloc.add(VoicePlayEvent());
515   - },
516   - child: Row(
517   - children: [
518   - SpeakerWidget(
519   - isPlaying: bloc.voicePlayState == VoicePlayState.playing, // 控制动画播放
520   - isClickable: true, // 控制是否可点击
521   - width: 45.w,
522   - height: 45.w,
523   - onTap: () {
524   - Log.d("Speaker tapped");
525   - },
526   - ),
527   - 10.horizontalSpace,
528   - Text(topics?.word ?? '')
529   - ],
530   - ),
  507 + Row(
  508 + children: [
  509 + SpeakerWidget(
  510 + isPlaying: bloc.voicePlayState == VoicePlayState.playing,
  511 + // 控制动画播放
  512 + isClickable: !bloc.isRecording,
  513 + // 控制是否可点击
  514 + width: 45.w,
  515 + height: 45.w,
  516 + onTap: () {
  517 + bloc.add(VoicePlayEvent());
  518 + },
  519 + ),
  520 + 10.horizontalSpace,
  521 + Text(topics?.word ?? '')
  522 + ],
531 523 ),
532 524 70.verticalSpace,
533   - ThrottledGestureDetector(
534   - throttleTime: 1000,
  525 + RecorderWidget(
  526 + isPlaying: bloc.isRecording,
  527 + isClickable: bloc.voicePlayState != VoicePlayState.playing,
  528 + width: 72.w,
  529 + height: 72.w,
535 530 onTap: () {
536   - if (bloc.voicePlayState == VoicePlayState.playing) {
537   - showToast('正在播放音频,不能终止');
538   - return;
539   - }
540   -
541   - if (bloc.isVoicing) {
  531 + if (bloc.isRecording) {
542 532 bloc.add(XSVoiceStopEvent());
543 533 return;
544 534 }
... ... @@ -551,14 +541,7 @@ class _TopicPicturePage extends StatelessWidget {
551 541 UserUtil.getUser()!.id.toString()));
552 542 }
553 543 },
554   - child: Image.asset(
555   - bloc.isVoicing
556   - ? 'micro_phone'.assetGif
557   - : 'micro_phone'.assetPng,
558   - height: 46.h,
559   - width: 46.w,
560   - ),
561   - )
  544 + ),
562 545 ],
563 546 )
564 547 ],
... ...