diff --git a/lib/common/widgets/record_playback_widget.dart b/lib/common/widgets/record_playback_widget.dart new file mode 100644 index 0000000..5f63226 --- /dev/null +++ b/lib/common/widgets/record_playback_widget.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; + +import '../../utils/log_util.dart'; + +/// 录音组件 +class _RecorderPlaybackWidget extends StatefulWidget { + final bool isClickable; + final bool isPlaying; + final VoidCallback? onTap; + final double width; + final double height; + + const _RecorderPlaybackWidget({ + Key? key, + required this.isClickable, + required this.isPlaying, + required this.onTap, + required this.width, + required this.height, + }) : super(key: key); + + @override + __RecorderPlaybackWidgetState createState() => + __RecorderPlaybackWidgetState(); +} + +class __RecorderPlaybackWidgetState extends State<_RecorderPlaybackWidget> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final LottieComposition _composition; + static const String TAG = "_RecorderPlaybackWidget"; + + bool _isPlaying = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _loadComposition(); + + _controller.addListener(() { + // Log.d("$TAG addListener _controller=${_controller.status}"); + }); + } + + Future _loadComposition() async { + final composition = + await AssetLottie('assets/lotties/recorder_back.zip').load(); + setState(() { + _composition = composition; + _controller.duration = _composition.duration; + }); + + if (widget.isPlaying) { + _playAnimation(); + } + } + + void _playAnimation() { + _controller.reset(); + _controller.repeat( + min: 2 / _composition.endFrame, + max: 22 / _composition.endFrame, + ); + } + + void _stopAnimation() { + _controller.stop(); + _resetAnimation(); + } + + void _resetAnimation() { + setState(() { + _isPlaying = false; + _controller.value = 1; + // _controller.repeat( + // min: 1 / _composition.endFrame, + // max: 1 / _composition.endFrame, + // ); + }); + } + + void _displayAnimation(bool clickable) { + if (clickable) { + _controller.value = 1; + // _controller.repeat( + // min: 1, + // max: 1, + // ); + } else { + _controller.value = 0; + // _controller.repeat( + // min: 0, + // max: 0, + // ); + } + } + + @override + void didUpdateWidget(_RecorderPlaybackWidget oldWidget) { + super.didUpdateWidget(oldWidget); + Log.d( + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); + if (widget.isPlaying && !_isPlaying) { + setState(() { + _isPlaying = true; + }); + _playAnimation(); + } else if (!widget.isPlaying && _isPlaying) { + _stopAnimation(); + } + + if (!_isPlaying) { + _displayAnimation(widget.isClickable); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ThrottledGestureDetector( + onTap: widget.isClickable ? widget.onTap : null, + child: Opacity( + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度 + child: Lottie( + composition: _composition, + controller: _controller, + renderCache: RenderCache.raster, + width: widget.width, + height: widget.height, + ), + ), + ); + } +} diff --git a/lib/common/widgets/recorder_widget.dart b/lib/common/widgets/recorder_widget.dart new file mode 100644 index 0000000..1169b63 --- /dev/null +++ b/lib/common/widgets/recorder_widget.dart @@ -0,0 +1,123 @@ +import 'package:flutter/material.dart'; +import 'package:lottie/lottie.dart'; +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; + +import '../../utils/log_util.dart'; + +/// 录音组件 +class RecorderWidget extends StatefulWidget { + final bool isClickable; + final bool isPlaying; + final VoidCallback? onTap; + final double width; + final double height; + + const RecorderWidget({ + Key? key, + required this.isClickable, + required this.isPlaying, + required this.onTap, + required this.width, + required this.height, + }) : super(key: key); + + @override + _RecorderWidgetState createState() => _RecorderWidgetState(); +} + +class _RecorderWidgetState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final LottieComposition _composition; + static const String TAG = "RecorderWidget"; + + bool _isPlaying = false; + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this); + _loadComposition(); + + _controller.addListener(() { + // Log.d("$TAG addListener _controller=${_controller.status}"); + }); + } + + Future _loadComposition() async { + final composition = await AssetLottie('assets/lotties/recorder_input.zip').load(); + setState(() { + _composition = composition; + _controller.duration = _composition.duration; + }); + + if (widget.isPlaying) { + _playInitialAnimation(); + } + } + + void _playInitialAnimation() { + _controller.reset(); + _controller + .animateTo(22 / _composition.endFrame) + .whenComplete(() => _loopMiddleAnimation()); + } + + void _loopMiddleAnimation() { + _controller.repeat( + min: 22 / _composition.endFrame, + max: 37 / _composition.endFrame, + ); + } + + void _playFinalAnimation() { + _controller.stop(); + _controller + .animateTo(50 / _composition.endFrame) + .whenComplete(() => _resetAnimation()); + } + + void _resetAnimation() { + setState(() { + _isPlaying = false; + _controller.value = 0; + }); + } + + @override + void didUpdateWidget(RecorderWidget oldWidget) { + super.didUpdateWidget(oldWidget); + Log.d("$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); + if (widget.isPlaying && !_isPlaying) { + setState(() { + _isPlaying = true; + }); + _playInitialAnimation(); + } else if (!widget.isPlaying && _isPlaying) { + _playFinalAnimation(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ThrottledGestureDetector( + onTap: widget.isClickable ? widget.onTap : null, + child: Opacity( + opacity: widget.isClickable ? 1.0 : 0.5, // 设置透明度 + child: Lottie( + composition: _composition, + controller: _controller, + renderCache: RenderCache.raster, + width: widget.width, + height: widget.height, + ), + ), + ); + } +} diff --git a/lib/common/widgets/speaker_widget.dart b/lib/common/widgets/speaker_widget.dart index 1851fd9..b969f02 100644 --- a/lib/common/widgets/speaker_widget.dart +++ b/lib/common/widgets/speaker_widget.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'dart:async'; import 'package:wow_english/common/extension/string_extension.dart'; +import 'package:wow_english/common/widgets/throttledGesture_gesture_detector.dart'; + +import '../../utils/log_util.dart'; /// 扬声器播放组件,帧动画 class SpeakerWidget extends StatefulWidget { @@ -34,6 +37,9 @@ class _SpeakerWidgetState extends State 'ic_speaker_play0'.assetPng, 'ic_speaker_play1'.assetPng, ]; + static const String TAG = "SpeakerWidget"; + + bool _isPlaying = false; @override void initState() { @@ -51,12 +57,15 @@ class _SpeakerWidgetState extends State @override void didUpdateWidget(SpeakerWidget oldWidget) { super.didUpdateWidget(oldWidget); - if (widget.isPlaying != oldWidget.isPlaying) { - if (widget.isPlaying) { - _startAnimation(); - } else { - _stopAnimation(); - } + Log.d( + "$TAG didUpdateWidget widget=${widget.isPlaying} oldWidget=${oldWidget.isPlaying} _isPlaying=$_isPlaying"); + if (widget.isPlaying && !_isPlaying) { + setState(() { + _isPlaying = true; + }); + _startAnimation(); + } else if (!widget.isPlaying && _isPlaying) { + _stopAnimation(); } } @@ -71,6 +80,7 @@ class _SpeakerWidgetState extends State void _stopAnimation() { _timer?.cancel(); setState(() { + _isPlaying = false; _currentFrame = 0; }); } @@ -84,13 +94,19 @@ class _SpeakerWidgetState extends State @override Widget build(BuildContext context) { - return GestureDetector( + return ThrottledGestureDetector( onTap: widget.isClickable ? widget.onTap : null, - child: Image.asset( - _speakerImagePaths[_currentFrame], - width: widget.width, - height: widget.height, - fit: BoxFit.cover, + child: Opacity( + opacity: widget.isClickable ? 1.0 : 0.5, + child: Image.asset( + _speakerImagePaths[_currentFrame], + width: widget.width, + height: widget.height, + fit: BoxFit.cover, + color: widget.isClickable ? null : Colors.transparent, + // 灰色遮罩 + colorBlendMode: widget.isClickable ? null : BlendMode.saturation, + ), ), ); } diff --git a/lib/common/widgets/throttledGesture_gesture_detector.dart b/lib/common/widgets/throttledGesture_gesture_detector.dart index 5cf70fe..0eb4c17 100644 --- a/lib/common/widgets/throttledGesture_gesture_detector.dart +++ b/lib/common/widgets/throttledGesture_gesture_detector.dart @@ -4,7 +4,7 @@ import 'package:flutter/material.dart'; ///带节流功能的GestureDetector class ThrottledGestureDetector extends StatefulWidget { final Widget child; - final VoidCallback onTap; + final VoidCallback? onTap; final int throttleTime; const ThrottledGestureDetector({ @@ -24,7 +24,9 @@ class _ThrottledGestureDetectorState extends State { void _handleTap() { if (!_isThrottled) { - widget.onTap(); + if (widget.onTap != null) { + widget.onTap!(); + } _isThrottled = true; Timer(Duration(milliseconds: widget.throttleTime), () { _isThrottled = false; diff --git a/lib/pages/practice/bloc/topic_picture_bloc.dart b/lib/pages/practice/bloc/topic_picture_bloc.dart index 4acb23e..a413865 100644 --- a/lib/pages/practice/bloc/topic_picture_bloc.dart +++ b/lib/pages/practice/bloc/topic_picture_bloc.dart @@ -53,7 +53,7 @@ class TopicPictureBloc CourseProcessEntity? get entity => _entity; ///正在评测 - bool _isVoicing = false; + bool _isRecording = false; ///正在播放音频 VoicePlayState _voicePlayState = VoicePlayState.unKnow; @@ -72,7 +72,7 @@ class TopicPictureBloc int get selectItem => _selectItem; - bool get isVoicing => _isVoicing; + bool get isRecording => _isRecording; VoicePlayState get voicePlayState => _voicePlayState; @@ -277,7 +277,7 @@ class TopicPictureBloc 'type': event.type, 'userId': event.userId.toString() }); - _isVoicing = true; + _isRecording = true; emitter(XSVoiceTestState()); } } @@ -300,7 +300,7 @@ class TopicPictureBloc final result = args['result'] as Map; final overall = result['overall'].toString(); showToast('测评成功,分数是$overall', duration: const Duration(seconds: 5)); - _isVoicing = false; + _isRecording = false; emitter(XSVoiceTestState()); if (isLastPage()) { showStepPage(); diff --git a/lib/pages/practice/topic_picture_page.dart b/lib/pages/practice/topic_picture_page.dart index 1090904..0cba6e7 100644 --- a/lib/pages/practice/topic_picture_page.dart +++ b/lib/pages/practice/topic_picture_page.dart @@ -11,9 +11,8 @@ import 'package:wow_english/pages/practice/widgets/shake_widget.dart'; import 'package:wow_english/route/route.dart'; import 'package:wow_english/utils/toast_util.dart'; +import '../../common/widgets/recorder_widget.dart'; import '../../common/widgets/speaker_widget.dart'; -import '../../common/widgets/throttledGesture_gesture_detector.dart'; -import '../../utils/log_util.dart'; import 'bloc/topic_picture_bloc.dart'; import 'widgets/practice_header_widget.dart'; @@ -167,10 +166,10 @@ class _TopicPicturePage extends StatelessWidget { builder: (context, state) { final bloc = BlocProvider.of(context); final isAnswerOption = bloc.selectItem == index; - final answerCorrect = isAnswerOption && - bloc.checkAnswerRight(index) == true; - final answerIncorrect = isAnswerOption && - bloc.checkAnswerRight(index) == false; + final answerCorrect = + isAnswerOption && bloc.checkAnswerRight(index) == true; + final answerIncorrect = + isAnswerOption && bloc.checkAnswerRight(index) == false; return Container( padding: EdgeInsets.symmetric(horizontal: 10.w), child: GestureDetector( @@ -239,10 +238,10 @@ class _TopicPicturePage extends StatelessWidget { builder: (context, state) { final bloc = BlocProvider.of(context); final isAnswerOption = bloc.selectItem == index; - final answerCorrect = isAnswerOption && - bloc.checkAnswerRight(index) == true; - final answerIncorrect = isAnswerOption && - bloc.checkAnswerRight(index) == false; + final answerCorrect = + isAnswerOption && bloc.checkAnswerRight(index) == true; + final answerIncorrect = + isAnswerOption && bloc.checkAnswerRight(index) == false; return Container( padding: EdgeInsets.symmetric(horizontal: 10.w), child: GestureDetector( @@ -348,10 +347,10 @@ class _TopicPicturePage extends StatelessWidget { builder: (context, state) { final bloc = BlocProvider.of(context); final isAnswerOption = bloc.selectItem == index; - final answerCorrect = isAnswerOption && - bloc.checkAnswerRight(index) == true; - final answerIncorrect = isAnswerOption && - bloc.checkAnswerRight(index) == false; + final answerCorrect = + isAnswerOption && bloc.checkAnswerRight(index) == true; + final answerIncorrect = + isAnswerOption && bloc.checkAnswerRight(index) == false; return ShakeWidget( shouldShake: answerIncorrect, child: Container( @@ -425,10 +424,10 @@ class _TopicPicturePage extends StatelessWidget { builder: (context, state) { final bloc = BlocProvider.of(context); final isAnswerOption = bloc.selectItem == index; - final answerCorrect = isAnswerOption && - bloc.checkAnswerRight(index) == true; - final answerIncorrect = isAnswerOption && - bloc.checkAnswerRight(index) == false; + final answerCorrect = + isAnswerOption && bloc.checkAnswerRight(index) == true; + final answerIncorrect = + isAnswerOption && bloc.checkAnswerRight(index) == false; return GestureDetector( onTap: () => bloc.add(SelectItemEvent(index)), child: ShakeWidget( @@ -505,40 +504,31 @@ class _TopicPicturePage extends StatelessWidget { Column( mainAxisAlignment: MainAxisAlignment.center, children: [ - GestureDetector( - onTap: () { - if (bloc.isVoicing) { - showToast('正在录音,不能终止'); - return; - } - bloc.add(VoicePlayEvent()); - }, - child: Row( - children: [ - SpeakerWidget( - isPlaying: bloc.voicePlayState == VoicePlayState.playing, // 控制动画播放 - isClickable: true, // 控制是否可点击 - width: 45.w, - height: 45.w, - onTap: () { - Log.d("Speaker tapped"); - }, - ), - 10.horizontalSpace, - Text(topics?.word ?? '') - ], - ), + Row( + children: [ + SpeakerWidget( + isPlaying: bloc.voicePlayState == VoicePlayState.playing, + // 控制动画播放 + isClickable: !bloc.isRecording, + // 控制是否可点击 + width: 45.w, + height: 45.w, + onTap: () { + bloc.add(VoicePlayEvent()); + }, + ), + 10.horizontalSpace, + Text(topics?.word ?? '') + ], ), 70.verticalSpace, - ThrottledGestureDetector( - throttleTime: 1000, + RecorderWidget( + isPlaying: bloc.isRecording, + isClickable: bloc.voicePlayState != VoicePlayState.playing, + width: 72.w, + height: 72.w, onTap: () { - if (bloc.voicePlayState == VoicePlayState.playing) { - showToast('正在播放音频,不能终止'); - return; - } - - if (bloc.isVoicing) { + if (bloc.isRecording) { bloc.add(XSVoiceStopEvent()); return; } @@ -551,14 +541,7 @@ class _TopicPicturePage extends StatelessWidget { UserUtil.getUser()!.id.toString())); } }, - child: Image.asset( - bloc.isVoicing - ? 'micro_phone'.assetGif - : 'micro_phone'.assetPng, - height: 46.h, - width: 46.w, - ), - ) + ), ], ) ],