Commit 11f396024b5c792686719dc170d6153d58a0195f
Merge branch 'feat-wqf-payment' into ios_umeng
Showing
40 changed files
with
1070 additions
and
328 deletions
android/app/proguard-rules.pro
... | ... | @@ -24,4 +24,22 @@ |
24 | 24 | -keep class com.tt.** { *; } |
25 | 25 | -keep class com.xs.** { *; } |
26 | 26 | -keep interface com.xs.** { *; } |
27 | --keep enum com.xs.** { *; } | |
28 | 27 | \ No newline at end of file |
28 | +-keep enum com.xs.** { *; } | |
29 | + | |
30 | +# 友盟混淆 | |
31 | +-keep class com.umeng.** { *; } | |
32 | + | |
33 | +-keep class com.uc.** { *; } | |
34 | + | |
35 | +-keep class com.efs.** { *; } | |
36 | + | |
37 | +-keepclassmembers class *{ | |
38 | + public<init>(org.json.JSONObject); | |
39 | +} | |
40 | +-keepclassmembers enum *{ | |
41 | + public static **[] values(); | |
42 | + public static ** valueOf(java.lang.String); | |
43 | +} | |
44 | +-keep public class com.kouyuxingqiu.wow_english.R$*{ | |
45 | + public static final int *; | |
46 | +} | |
29 | 47 | \ No newline at end of file | ... | ... |
android/app/src/main/AndroidManifest.xml
... | ... | @@ -24,6 +24,8 @@ |
24 | 24 | <intent-filter> |
25 | 25 | <action android:name="android.intent.action.MAIN"/> |
26 | 26 | <category android:name="android.intent.category.LAUNCHER"/> |
27 | + <!-- 友盟apm集成测试用 --> | |
28 | +<!-- <data android:scheme="um.663b66b0b3362515012f4ea5" />--> | |
27 | 29 | </intent-filter> |
28 | 30 | </activity> |
29 | 31 | <!-- Don't delete the meta-data below. | ... | ... |
android/app/src/main/kotlin/com/kouyuxingqiu/wow_english/MainActivity.kt
... | ... | @@ -12,6 +12,8 @@ import com.kouyuxingqiu.wow_english.methodChannels.GameMethodChannel |
12 | 12 | import com.kouyuxingqiu.wow_english.methodChannels.SingSoungMethodChannel |
13 | 13 | import com.umeng.commonsdk.UMConfigure |
14 | 14 | import io.flutter.embedding.android.FlutterActivity |
15 | +import com.umeng.umcrash.UMCrash | |
16 | + | |
15 | 17 | |
16 | 18 | class MainActivity : FlutterActivity() { |
17 | 19 | override fun onCreate(savedInstanceState: Bundle?) { |
... | ... | @@ -23,9 +25,7 @@ class MainActivity : FlutterActivity() { |
23 | 25 | SingSoungMethodChannel(this, it) |
24 | 26 | GameMethodChannel(this, it) |
25 | 27 | } |
26 | - //UM日志打印 | |
27 | - UMConfigure.setLogEnabled(true) | |
28 | - UMConfigure.preInit(this, "663b66b0b3362515012f4ea5", "official") | |
28 | + initUmeng() | |
29 | 29 | } |
30 | 30 | |
31 | 31 | override fun onResume() { |
... | ... | @@ -56,4 +56,27 @@ class MainActivity : FlutterActivity() { |
56 | 56 | // 打开沉浸式 |
57 | 57 | WindowCompat.setDecorFitsSystemWindows(window, false)*/ |
58 | 58 | } |
59 | + | |
60 | + /** | |
61 | + * 友盟初始化 | |
62 | + */ | |
63 | + private fun initUmeng() { | |
64 | + // 友盟集成测试阶段获取UMID | |
65 | +// android.util.Log.d("WQF UMConfigure", UMConfigure.getUMIDString(this)) | |
66 | + // 在application.onCreate内配置各模块开关并预初始化SDK | |
67 | + // 重点关注:如果您还想采集Native 崩溃、ANR等日志可以参考下面设置 | |
68 | + UMCrash.initConfig(Bundle().apply { | |
69 | + putBoolean(UMCrash.KEY_ENABLE_CRASH_JAVA, true) | |
70 | + putBoolean(UMCrash.KEY_ENABLE_CRASH_NATIVE, true) | |
71 | + putBoolean(UMCrash.KEY_ENABLE_ANR, true) | |
72 | + putBoolean(UMCrash.KEY_ENABLE_PA, false) | |
73 | + putBoolean(UMCrash.KEY_ENABLE_LAUNCH, false) | |
74 | + putBoolean(UMCrash.KEY_ENABLE_MEM, false) | |
75 | + putBoolean(UMCrash.KEY_ENABLE_H5PAGE, false) | |
76 | + putBoolean(UMCrash.KEY_ENABLE_POWER, false) | |
77 | + }) | |
78 | + //UM日志打印 | |
79 | + UMConfigure.setLogEnabled(false) | |
80 | + UMConfigure.preInit(this, "663b66b0b3362515012f4ea5", "official") | |
81 | + } | |
59 | 82 | } | ... | ... |
assets/images/section_finish_again.png
0 → 100644
17.3 KB
assets/images/section_finish_next.png
0 → 100644
13.8 KB
assets/images/section_finish_steve.png
0 → 100644
34.7 KB
build.yaml
0 → 100644
lib/app/app.dart
... | ... | @@ -3,6 +3,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; |
3 | 3 | import 'package:flutter_easyloading/flutter_easyloading.dart'; |
4 | 4 | import 'package:flutter_screenutil/flutter_screenutil.dart'; |
5 | 5 | import 'package:responsive_framework/responsive_framework.dart'; |
6 | +import 'package:umeng_apm_sdk/umeng_apm_sdk.dart'; | |
6 | 7 | import 'package:wow_english/common/blocs/cachebloc/cache_bloc.dart'; |
7 | 8 | import 'package:wow_english/common/widgets/hide_keyboard_widget.dart'; |
8 | 9 | import 'package:wow_english/pages/tab/blocs/tab_bloc.dart'; |
... | ... | @@ -10,7 +11,9 @@ import 'package:wow_english/pages/user/bloc/user_bloc.dart'; |
10 | 11 | import 'package:wow_english/route/route.dart'; |
11 | 12 | |
12 | 13 | class App extends StatelessWidget { |
13 | - const App({super.key}); | |
14 | + const App([this._navigatorObserver]); | |
15 | + | |
16 | + final NavigatorObserver? _navigatorObserver; | |
14 | 17 | |
15 | 18 | @override |
16 | 19 | Widget build(BuildContext context) { |
... | ... | @@ -41,6 +44,11 @@ class App extends StatelessWidget { |
41 | 44 | initialRoute: AppRouteName.splash, |
42 | 45 | navigatorKey: AppRouter.navigatorKey, |
43 | 46 | onGenerateRoute: AppRouter.generateRoute, |
47 | + navigatorObservers: <NavigatorObserver>[ | |
48 | + // 带入ApmNavigatorObserver实例用于路由监听 | |
49 | + // 如果不带入SDK监听器将无法获知页面(PV)入栈退栈行为,错误率(Dart异常数/FlutterPV次数)将异常攀升。 | |
50 | + _navigatorObserver ?? ApmNavigatorObserver.singleInstance | |
51 | + ], | |
44 | 52 | ), |
45 | 53 | )), |
46 | 54 | ); | ... | ... |
lib/common/request/apis.dart
... | ... | @@ -89,9 +89,12 @@ class Apis { |
89 | 89 | /// 进入课堂 |
90 | 90 | static const String enterClass = 'course/enter/class'; |
91 | 91 | |
92 | - /// 退出课堂 | |
92 | + /// 退出课堂(非完整、中断) | |
93 | 93 | static const String exitClass = 'course/exit/class'; |
94 | 94 | |
95 | + /// 结束课堂(完整) | |
96 | + static const String endClass = 'course/end/class'; | |
97 | + | |
95 | 98 | /// 商品列表 |
96 | 99 | static const String productList = 'order/course/combo/list'; |
97 | 100 | ... | ... |
lib/common/request/basic_config.dart
1 | -import 'package:flutter/cupertino.dart'; | |
1 | +import 'package:flutter/foundation.dart'; | |
2 | 2 | |
3 | 3 | class BasicConfig { |
4 | 4 | // static bool isTestDev = true; |
... | ... | @@ -7,7 +7,6 @@ class BasicConfig { |
7 | 7 | |
8 | 8 | // 暂时未启用 |
9 | 9 | static bool isEnvProd() { |
10 | - bool kReleaseMode = const bool.fromEnvironment('dart.vm.product'); | |
11 | 10 | if (kReleaseMode) { |
12 | 11 | debugPrint("dart.vm.product-现在是release环境."); |
13 | 12 | } else { | ... | ... |
lib/common/request/dao/listen_dao.dart
... | ... | @@ -4,6 +4,7 @@ import 'package:wow_english/models/follow_read_entity.dart'; |
4 | 4 | import 'package:wow_english/models/listen_entity.dart'; |
5 | 5 | |
6 | 6 | import '../../../models/read_content_entity.dart'; |
7 | +import '../../../utils/date_util.dart'; | |
7 | 8 | |
8 | 9 | class ListenDao { |
9 | 10 | /// 磨耳朵 |
... | ... | @@ -14,37 +15,66 @@ class ListenDao { |
14 | 15 | |
15 | 16 | ///视频跟读 |
16 | 17 | static Future<List<FollowReadEntity?>?> followRead() async { |
17 | - var data = await requestClient.get<List<FollowReadEntity?>>(Apis.followRead); | |
18 | + var data = | |
19 | + await requestClient.get<List<FollowReadEntity?>>(Apis.followRead); | |
18 | 20 | return data; |
19 | 21 | } |
20 | 22 | |
21 | 23 | ///课程内容 |
22 | 24 | static Future<CourseProcessEntity?> process(courseLessonId) async { |
23 | - var data = await requestClient.get<CourseProcessEntity>(Apis.process,queryParameters: {'courseLessonId':courseLessonId}); | |
25 | + var data = await requestClient.get<CourseProcessEntity>(Apis.process, | |
26 | + queryParameters: {'courseLessonId': courseLessonId}); | |
24 | 27 | return data; |
25 | 28 | } |
26 | 29 | |
27 | 30 | ///获取视频跟读内容 |
28 | - static Future<List<ReadContentEntity?>?> readContent(videoFollowReadId) async { | |
29 | - var data = await requestClient.get<List<ReadContentEntity?>>(Apis.readContent,queryParameters: {'videoFollowReadId':videoFollowReadId}); | |
31 | + static Future<List<ReadContentEntity?>?> readContent( | |
32 | + videoFollowReadId) async { | |
33 | + var data = await requestClient.get<List<ReadContentEntity?>>( | |
34 | + Apis.readContent, | |
35 | + queryParameters: {'videoFollowReadId': videoFollowReadId}); | |
30 | 36 | return data; |
31 | 37 | } |
32 | 38 | |
33 | 39 | ///视频跟读提交结果 |
34 | - static Future followResult(frequency,videoFollowReadId) async { | |
35 | - var data = await requestClient.post(Apis.followResult,data: {'frequency':frequency,'videoFollowReadContentId':videoFollowReadId}); | |
40 | + static Future followResult(frequency, videoFollowReadId) async { | |
41 | + var data = await requestClient.post(Apis.followResult, data: { | |
42 | + 'frequency': frequency, | |
43 | + 'videoFollowReadContentId': videoFollowReadId | |
44 | + }); | |
36 | 45 | return data; |
37 | 46 | } |
38 | 47 | |
39 | 48 | ///进入课堂 |
40 | 49 | static Future enterClass(courseLessonId) async { |
41 | - var data = await requestClient.post(Apis.enterClass,data: {'courseLessonId':courseLessonId}); | |
50 | + var data = await requestClient | |
51 | + .post(Apis.enterClass, data: {'courseLessonId': courseLessonId}); | |
42 | 52 | return data; |
43 | 53 | } |
44 | 54 | |
45 | 55 | ///退出课堂 |
46 | - static Future exitClass(courseLessonId,currentStep,currentTime) async { | |
47 | - var data = await requestClient.post(Apis.exitClass,data: {'courseLessonId':courseLessonId,'currentStep':currentStep,'currentTime':currentTime}); | |
56 | + static Future exitClass(courseLessonId, | |
57 | + {int? currentStep, int? currentTime}) async { | |
58 | + var data = await requestClient.post(Apis.exitClass, data: { | |
59 | + 'courseLessonId': courseLessonId, | |
60 | + | |
61 | + ///如果currentStep不为空,才传currentStep参数 | |
62 | + if (currentStep != null) 'currentStep': currentStep, | |
63 | + | |
64 | + ///如果currentTime不为空,才传currentTime参数 | |
65 | + if (currentTime != null) 'currentTime': currentTime | |
66 | + }); | |
67 | + return data; | |
68 | + } | |
69 | + | |
70 | + ///完成课堂 | |
71 | + static Future endClass(courseLessonId, | |
72 | + {int? currentStep, int? currentTime}) async { | |
73 | + var data = await requestClient.post(Apis.endClass, data: { | |
74 | + 'courseLessonId': courseLessonId, | |
75 | + if (currentStep != null) 'currentStep': currentStep, | |
76 | + if (currentTime != null) 'currentTime': currentTime | |
77 | + }); | |
48 | 78 | return data; |
49 | 79 | } |
50 | 80 | } | ... | ... |
lib/main.dart
1 | 1 | import 'dart:io'; |
2 | 2 | |
3 | +import 'package:flutter/foundation.dart'; | |
3 | 4 | import 'package:flutter/material.dart'; |
4 | 5 | import 'package:flutter/services.dart'; |
6 | +import 'package:package_info_plus/package_info_plus.dart'; | |
7 | +import 'package:umeng_apm_sdk/umeng_apm_sdk.dart'; | |
5 | 8 | import 'package:wow_english/app/app.dart'; |
6 | 9 | |
10 | +// void main() { | |
11 | +// ///设置设备默认方向 | |
12 | +// WidgetsFlutterBinding.ensureInitialized(); | |
13 | +// if (Platform.isAndroid) { | |
14 | +// SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( //设置状态栏透明 | |
15 | +// statusBarColor: Colors.transparent, | |
16 | +// )); | |
17 | +// } | |
18 | +// runApp(const App()); | |
19 | +// } | |
20 | + | |
7 | 21 | void main() { |
8 | 22 | ///设置设备默认方向 |
9 | - WidgetsFlutterBinding.ensureInitialized(); | |
10 | 23 | if (Platform.isAndroid) { |
11 | 24 | SystemChrome.setSystemUIOverlayStyle(const SystemUiOverlayStyle( //设置状态栏透明 |
12 | 25 | statusBarColor: Colors.transparent, |
13 | 26 | )); |
14 | 27 | } |
15 | - runApp(const App()); | |
28 | + | |
29 | + final UmengApmSdk umengApmSdk = UmengApmSdk( | |
30 | + name: "", | |
31 | + bver: "", | |
32 | + | |
33 | + // 是否开启SDK运行时日志输出 | |
34 | + enableLog: !kReleaseMode, | |
35 | + | |
36 | + // 您使用的flutter版本,默认为空,为方便定位访问,建议配置 | |
37 | + flutterVersion: '3.13.2', | |
38 | + | |
39 | + // 您使用的flutter引擎版本 | |
40 | + engineVersion: 'ff5b5b5fa6', | |
41 | + | |
42 | + // 开启监测页面帧率(默认关闭) 版本 v2.1.3 可支持 | |
43 | + enableTrackingPageFps: !kReleaseMode, | |
44 | + | |
45 | + // 开启监测页面性能(默认关闭)版本 v2.1.3 可支持 | |
46 | + enableTrackingPagePerf: !kReleaseMode, | |
47 | + | |
48 | + // 带入继承ApmWidgetsFlutterBinding的覆写和初始化方法, 可用于自定义监听应用生命周期 | |
49 | + // 确保去掉原有的WidgetsFlutterBinding.ensureInitialized() ,以免出现重复初始化绑定的异常造成无法正常初始化,SDK内部已通过initFlutterBinding入参带入继承的WidgetsFlutterBinding实现初始化操作 | |
50 | + initFlutterBinding: MyApmWidgetsFlutterBinding.ensureInitialized, | |
51 | + | |
52 | + // 抛出异常事件 | |
53 | + onError: (exception, stack) { | |
54 | + debugPrint(exception.toString()); | |
55 | + }, | |
56 | + | |
57 | + ); | |
58 | + umengApmSdk.init(appRunner: (observer) async { | |
59 | + // 确保去掉原有的WidgetsFlutterBinding.ensureInitialized() ,以免出现重复初始化绑定的异常造成无法正常初始化,SDK内部已通过initFlutterBinding入参带入继承的WidgetsFlutterBinding实现初始化操作 | |
60 | + // 依赖ensureInitialized()初始化的代码可在此调用 | |
61 | + // 需要异步获取设置应用名称和版本号可在此回调中操作 | |
62 | + // SDK实例化的设置可先将name和bver 为 "",然后通过以下方式进行设置 | |
63 | + PackageInfo packageInfo = await PackageInfo.fromPlatform(); | |
64 | + String packageName = packageInfo.packageName; | |
65 | + String buildNumber = packageInfo.buildNumber; | |
66 | + String version = packageInfo.version; | |
67 | + umengApmSdk.name = packageName; | |
68 | + umengApmSdk.bver = '$version+$buildNumber'; | |
69 | + | |
70 | + return App(observer); | |
71 | + }); | |
72 | +} | |
73 | + | |
74 | + | |
75 | +//ApmWidgetsFlutterBinding的覆写和初始化方法 | |
76 | +class MyApmWidgetsFlutterBinding extends ApmWidgetsFlutterBinding { | |
77 | + @override | |
78 | + void handleAppLifecycleStateChanged(AppLifecycleState state) { | |
79 | + // 添加自己的实现逻辑 | |
80 | + debugPrint('AppLifecycleState changed to $state'); | |
81 | + super.handleAppLifecycleStateChanged(state); | |
82 | + } | |
83 | + | |
84 | + static WidgetsBinding? ensureInitialized() { | |
85 | + MyApmWidgetsFlutterBinding(); | |
86 | + return WidgetsBinding.instance; | |
87 | + } | |
16 | 88 | } | ... | ... |
lib/pages/practice/bloc/topic_picture_bloc.dart
... | ... | @@ -9,10 +9,14 @@ import 'package:wow_english/common/extension/string_extension.dart'; |
9 | 9 | import 'package:wow_english/common/request/dao/listen_dao.dart'; |
10 | 10 | import 'package:wow_english/common/request/exception.dart'; |
11 | 11 | import 'package:wow_english/models/course_process_entity.dart'; |
12 | +import 'package:wow_english/pages/section/subsection/base_section/bloc.dart'; | |
13 | +import 'package:wow_english/pages/section/subsection/base_section/event.dart'; | |
14 | +import 'package:wow_english/pages/section/subsection/base_section/state.dart'; | |
12 | 15 | import 'package:wow_english/utils/loading.dart'; |
13 | 16 | import 'package:wow_english/utils/toast_util.dart'; |
14 | 17 | |
15 | 18 | import '../../../common/permission/permissionRequestPage.dart'; |
19 | +import '../../../route/route.dart'; | |
16 | 20 | |
17 | 21 | part 'topic_picture_event.dart'; |
18 | 22 | part 'topic_picture_state.dart'; |
... | ... | @@ -28,7 +32,7 @@ enum VoicePlayState { |
28 | 32 | stop |
29 | 33 | } |
30 | 34 | |
31 | -class TopicPictureBloc extends Bloc<TopicPictureEvent, TopicPictureState> { | |
35 | +class TopicPictureBloc extends BaseSectionBloc<TopicPictureEvent, TopicPictureState> { | |
32 | 36 | |
33 | 37 | final PageController pageController; |
34 | 38 | |
... | ... | @@ -94,11 +98,15 @@ class TopicPictureBloc extends Bloc<TopicPictureEvent, TopicPictureState> { |
94 | 98 | _forbiddenWhenCorrect = false; |
95 | 99 | debugPrint('播放完成后解除禁止'); |
96 | 100 | if (event == PlayerState.completed) { |
97 | - // 答对后且播放完自动翻页 | |
98 | - pageController.nextPage( | |
99 | - duration: const Duration(milliseconds: 500), | |
100 | - curve: Curves.ease, | |
101 | - ); | |
101 | + if (isLastPage()) { | |
102 | + showStepPage(); | |
103 | + } else { | |
104 | + // 答对后且播放完自动翻页 | |
105 | + pageController.nextPage( | |
106 | + duration: const Duration(milliseconds: 500), | |
107 | + curve: Curves.ease, | |
108 | + ); | |
109 | + } | |
102 | 110 | } |
103 | 111 | } |
104 | 112 | } |
... | ... | @@ -263,6 +271,9 @@ class TopicPictureBloc extends Bloc<TopicPictureEvent, TopicPictureState> { |
263 | 271 | showToast('测评成功,分数是$overall',duration: const Duration(seconds: 5)); |
264 | 272 | _isVoicing = false; |
265 | 273 | emitter(XSVoiceTestState()); |
274 | + if (isLastPage()) { | |
275 | + showStepPage(); | |
276 | + } | |
266 | 277 | } |
267 | 278 | |
268 | 279 | // 暂时没用上 |
... | ... | @@ -288,6 +299,7 @@ class TopicPictureBloc extends Bloc<TopicPictureEvent, TopicPictureState> { |
288 | 299 | } |
289 | 300 | } |
290 | 301 | |
302 | + ///播放选择结果音效 | |
291 | 303 | void _playResultSound(bool isCorrect) async { |
292 | 304 | // await audioPlayer.stop(); |
293 | 305 | if (audioPlayer.state == PlayerState.playing && _isResultSoundPlaying == false) { |
... | ... | @@ -302,4 +314,26 @@ class TopicPictureBloc extends Bloc<TopicPictureEvent, TopicPictureState> { |
302 | 314 | await audioPlayer.play(AssetSource('incorrect_voice'.assetMp3)); |
303 | 315 | } |
304 | 316 | } |
317 | + | |
318 | + ///是否是最后一页 | |
319 | + bool isLastPage() { | |
320 | + return currentPage == _entity?.topics?.length; | |
321 | + } | |
322 | + | |
323 | + ///展示过渡页 | |
324 | + void showStepPage() { | |
325 | + ///如果最后一页是语音问答题,评测完后自动翻页 | |
326 | + sectionComplete(() { | |
327 | + popPage( | |
328 | + data:{ | |
329 | + 'currentStep':currentPage, | |
330 | + 'courseLessonId':courseLessonId, | |
331 | + 'isCompleted': true, | |
332 | + 'nextSection': true | |
333 | + }); | |
334 | + }, againSectionTap: () { | |
335 | + debugPrint("WQF 重做"); | |
336 | + pageController.jumpToPage(0); | |
337 | + }); | |
338 | + } | |
305 | 339 | } | ... | ... |
lib/pages/practice/bloc/topic_picture_event.dart
lib/pages/practice/bloc/topic_picture_state.dart
lib/pages/practice/topic_picture_page.dart
... | ... | @@ -6,6 +6,7 @@ import 'package:wow_english/common/core/user_util.dart'; |
6 | 6 | import 'package:wow_english/common/extension/string_extension.dart'; |
7 | 7 | import 'package:wow_english/common/widgets/ow_image_widget.dart'; |
8 | 8 | import 'package:wow_english/models/course_process_entity.dart'; |
9 | +import 'package:wow_english/pages/practice/topic_type.dart'; | |
9 | 10 | import 'package:wow_english/route/route.dart'; |
10 | 11 | import 'package:wow_english/utils/toast_util.dart'; |
11 | 12 | |
... | ... | @@ -70,8 +71,9 @@ class _TopicPicturePage extends StatelessWidget { |
70 | 71 | onTap: () { |
71 | 72 | popPage( |
72 | 73 | data:{ |
73 | - 'currentStep':bloc.currentPage.toString(), | |
74 | - 'courseLessonId':bloc.courseLessonId | |
74 | + 'currentStep':bloc.currentPage, | |
75 | + 'courseLessonId':bloc.courseLessonId, | |
76 | + 'isCompleted': bloc.isLastPage(), | |
75 | 77 | }); |
76 | 78 | // Navigator.pop(context); |
77 | 79 | }, |
... | ... | @@ -86,13 +88,13 @@ class _TopicPicturePage extends StatelessWidget { |
86 | 88 | }, |
87 | 89 | itemBuilder: (BuildContext context,int index){ |
88 | 90 | CourseProcessTopics? topics = bloc.entity?.topics![index]; |
89 | - if (topics?.type == 1) {//听音选图 | |
91 | + if (topics?.type == TopicType.audioImageSelect.value) {//听音选图 | |
90 | 92 | return _pageViewVoicePictureItemWidget(topics); |
91 | - } else if (topics?.type == 2) {//听音选字 | |
93 | + } else if (topics?.type == TopicType.audioCharSelect.value) {//听音选字 | |
92 | 94 | return _pageViewVoiceWordItemWidget(topics); |
93 | - } else if (topics?.type == 3) {//看题选字 | |
95 | + } else if (topics?.type == TopicType.questionCharSelect.value) {//看题选字 | |
94 | 96 | return _pageViewWordItemWidget(topics); |
95 | - } else if (topics?.type == 4) {//看题选图 | |
97 | + } else if (topics?.type == TopicType.questionImageSelect.value) {//看题选图 | |
96 | 98 | return _pageViewItemWidget(topics); |
97 | 99 | } else {//语音问答 |
98 | 100 | return _voiceAnswerItem(topics); | ... | ... |
lib/pages/practice/topic_type.dart
0 → 100644
1 | +///练习(题型)类型 | |
2 | +enum TopicType { | |
3 | + ///听音选图 | |
4 | + audioImageSelect, | |
5 | + | |
6 | + ///听音选字 | |
7 | + audioCharSelect, | |
8 | + | |
9 | + ///看题选字 | |
10 | + questionCharSelect, | |
11 | + | |
12 | + ///看题选图 | |
13 | + questionImageSelect, | |
14 | + | |
15 | + ///语音问答 | |
16 | + voiceQuestion | |
17 | +} | |
18 | + | |
19 | +extension TopicTypeExtension on TopicType { | |
20 | + int get value { | |
21 | + switch (this) { | |
22 | + case TopicType.audioImageSelect: | |
23 | + return 1; | |
24 | + case TopicType.audioCharSelect: | |
25 | + return 2; | |
26 | + case TopicType.questionCharSelect: | |
27 | + return 3; | |
28 | + case TopicType.questionImageSelect: | |
29 | + return 4; | |
30 | + case TopicType.voiceQuestion: | |
31 | + return 5; | |
32 | + default: | |
33 | + throw ArgumentError('Unknown topic type'); | |
34 | + } | |
35 | + } | |
36 | +} | ... | ... |
lib/pages/reading/bloc/reading_bloc.dart
... | ... | @@ -6,11 +6,15 @@ import 'package:flutter_bloc/flutter_bloc.dart'; |
6 | 6 | import 'package:flutter_easyloading/flutter_easyloading.dart'; |
7 | 7 | import 'package:permission_handler/permission_handler.dart'; |
8 | 8 | import 'package:wow_english/pages/reading/widgets/ReadingModeType.dart'; |
9 | +import 'package:wow_english/pages/section/subsection/base_section/bloc.dart'; | |
10 | +import 'package:wow_english/pages/section/subsection/base_section/event.dart'; | |
11 | +import 'package:wow_english/pages/section/subsection/base_section/state.dart'; | |
9 | 12 | |
10 | 13 | import '../../../common/core/user_util.dart'; |
11 | 14 | import '../../../common/request/dao/listen_dao.dart'; |
12 | 15 | import '../../../common/request/exception.dart'; |
13 | 16 | import '../../../models/course_process_entity.dart'; |
17 | +import '../../../route/route.dart'; | |
14 | 18 | import '../../../utils/loading.dart'; |
15 | 19 | |
16 | 20 | import '../../../utils/log_util.dart'; |
... | ... | @@ -33,7 +37,8 @@ enum VoicePlayState { |
33 | 37 | stop |
34 | 38 | } |
35 | 39 | |
36 | -class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { | |
40 | +class ReadingPageBloc | |
41 | + extends BaseSectionBloc<ReadingPageEvent, ReadingPageState> { | |
37 | 42 | final PageController pageController; |
38 | 43 | |
39 | 44 | final String courseLessonId; |
... | ... | @@ -211,7 +216,7 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
211 | 216 | } |
212 | 217 | |
213 | 218 | ///播放原音音频 |
214 | - Future<void> _playOriginalAudioInner(String? audioUrl) async { | |
219 | + void _playOriginalAudioInner(String? audioUrl) async { | |
215 | 220 | if (_isRecordAudioPlaying) { |
216 | 221 | _isRecordAudioPlaying = false; |
217 | 222 | } |
... | ... | @@ -219,7 +224,7 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
219 | 224 | "_playOriginalAudio _isRecordAudioPlaying=$_isRecordAudioPlaying _isOriginAudioPlaying=$_isOriginAudioPlaying url=$audioUrl"); |
220 | 225 | if (_isOriginAudioPlaying) { |
221 | 226 | _isOriginAudioPlaying = false; |
222 | - await audioPlayer.stop(); | |
227 | + audioPlayer.stop(); | |
223 | 228 | } else { |
224 | 229 | _isOriginAudioPlaying = true; |
225 | 230 | audioUrl ??= currentPageData()?.audioUrl ?? ''; |
... | ... | @@ -265,8 +270,17 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
265 | 270 | } |
266 | 271 | |
267 | 272 | void nextPage() { |
268 | - if (_currentPage >= dataCount() - 1) { | |
269 | - ///todo 最后一页了 | |
273 | + if (currentPage >= dataCount()) { | |
274 | + sectionComplete(() { | |
275 | + popPage(data: { | |
276 | + 'currentStep': currentPage, | |
277 | + 'courseLessonId': courseLessonId, | |
278 | + 'isCompleted': true, | |
279 | + 'nextSection': true | |
280 | + }); | |
281 | + }, againSectionTap: () { | |
282 | + pageController.jumpToPage(0); | |
283 | + }); | |
270 | 284 | } else { |
271 | 285 | _currentPage += 1; |
272 | 286 | pageController.nextPage( |
... | ... | @@ -291,14 +305,14 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
291 | 305 | |
292 | 306 | void startRecord(String content) async { |
293 | 307 | // 调用封装好的权限检查和请求方法 |
294 | - bool result = await permissionCheckAndRequest( | |
295 | - context, | |
296 | - Permission.microphone, | |
297 | - "录音" | |
298 | - ); | |
308 | + bool result = | |
309 | + await permissionCheckAndRequest(context, Permission.microphone, "录音"); | |
299 | 310 | if (result) { |
300 | - methodChannel.invokeMethod( | |
301 | - 'startVoice', {'word': content, 'type': '0', 'userId': UserUtil.getUser()?.id.toString()}); | |
311 | + methodChannel.invokeMethod('startVoice', { | |
312 | + 'word': content, | |
313 | + 'type': '0', | |
314 | + 'userId': UserUtil.getUser()?.id.toString() | |
315 | + }); | |
302 | 316 | } |
303 | 317 | } |
304 | 318 | |
... | ... | @@ -313,7 +327,20 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
313 | 327 | currentPageData()?.recordScore = overall; |
314 | 328 | currentPageData()?.recordUrl = args['audioUrl'] + '.mp3'; |
315 | 329 | ///完成录音后紧接着播放录音 |
316 | - _playRecordAudioInner(); | |
330 | + await _playRecordAudioInner(); | |
331 | + if (isLastPage()) { | |
332 | + sectionComplete(() { | |
333 | + popPage(data: { | |
334 | + 'currentStep': currentPage, | |
335 | + 'courseLessonId': courseLessonId, | |
336 | + 'isCompleted': true, | |
337 | + 'nextSection': true | |
338 | + }); | |
339 | + }, againSectionTap: () { | |
340 | + _resetLocalResult(); | |
341 | + pageController.jumpToPage(0); | |
342 | + }); | |
343 | + } | |
317 | 344 | // emitter(FeedbackState()); |
318 | 345 | } |
319 | 346 | |
... | ... | @@ -338,8 +365,11 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
338 | 365 | nextPage(); |
339 | 366 | } |
340 | 367 | |
341 | - Log.d("_onAudioPlayComplete _isOriginAudioPlaying=$_isOriginAudioPlaying _voicePlayState=$_voicePlayState recordUrl=${currentPageData()?.recordUrl?.isNotEmpty}"); | |
342 | - if (_isOriginAudioPlaying && _voicePlayState == VoicePlayState.completed && currentPageData()?.recordUrl?.isNotEmpty != true) { | |
368 | + Log.d( | |
369 | + "_onAudioPlayComplete _isOriginAudioPlaying=$_isOriginAudioPlaying _voicePlayState=$_voicePlayState recordUrl=${currentPageData()?.recordUrl?.isNotEmpty}"); | |
370 | + if (_isOriginAudioPlaying && | |
371 | + _voicePlayState == VoicePlayState.completed && | |
372 | + currentPageData()?.recordUrl?.isNotEmpty != true) { | |
343 | 373 | ///如果刚刚完成原音播放&&录音为空,则开始录音 |
344 | 374 | startRecord(currentPageData()?.word ?? ''); |
345 | 375 | } |
... | ... | @@ -354,11 +384,21 @@ class ReadingPageBloc extends Bloc<ReadingPageEvent, ReadingPageState> { |
354 | 384 | _isRecordAudioPlaying = false; |
355 | 385 | } |
356 | 386 | |
357 | - void _onVoiceXsStateChange( | |
358 | - OnXSVoiceStateChangeEvent event, | |
359 | - Emitter<ReadingPageState> emitter | |
360 | - ) async { | |
387 | + void _onVoiceXsStateChange(OnXSVoiceStateChangeEvent event, | |
388 | + Emitter<ReadingPageState> emitter) async { | |
361 | 389 | emitter(XSVoiceTestState()); |
362 | 390 | } |
363 | -} | |
364 | 391 | |
392 | + ///是否是最后一页 | |
393 | + bool isLastPage() { | |
394 | + return currentPage == dataCount(); | |
395 | + } | |
396 | + | |
397 | + ///重置数据 | |
398 | + void _resetLocalResult() { | |
399 | + _entity?.readings?.forEach((element) { | |
400 | + element.recordScore = null; | |
401 | + element.recordUrl = null; | |
402 | + }); | |
403 | + } | |
404 | +} | ... | ... |
lib/pages/reading/bloc/reading_event.dart
lib/pages/reading/bloc/reading_state.dart
lib/pages/reading/reading_page.dart
... | ... | @@ -88,8 +88,9 @@ class _ReadingPage extends StatelessWidget { |
88 | 88 | onPressed: () { |
89 | 89 | popPage( |
90 | 90 | data:{ |
91 | - 'currentStep':bloc.currentPage.toString(), | |
92 | - 'courseLessonId':bloc.courseLessonId | |
91 | + 'currentStep':bloc.currentPage, | |
92 | + 'courseLessonId':bloc.courseLessonId, | |
93 | + 'isCompleted':bloc.isLastPage(), | |
93 | 94 | } |
94 | 95 | ); |
95 | 96 | }, | ... | ... |
lib/pages/repeataftercontent/widgets/repeat_video_widget.dart
... | ... | @@ -140,13 +140,14 @@ class _RepeatVideoWidgetState extends State<RepeatVideoWidget> { |
140 | 140 | : |
141 | 141 | Container( |
142 | 142 | color: Colors.white, |
143 | - child: Text( | |
144 | - '视频加载中....', | |
145 | - style: TextStyle( | |
146 | - fontSize: 20.sp, | |
147 | - color: Colors.black | |
148 | - ), | |
149 | - ), | |
143 | + child: const CircularProgressIndicator(), | |
144 | + // Text( | |
145 | + // '视频加载中....', | |
146 | + // style: TextStyle( | |
147 | + // fontSize: 20.sp, | |
148 | + // color: Colors.black | |
149 | + // ), | |
150 | + // ), | |
150 | 151 | ), |
151 | 152 | ); |
152 | 153 | } | ... | ... |
lib/pages/section/bloc/section_bloc.dart
... | ... | @@ -10,39 +10,55 @@ import 'package:wow_english/utils/toast_util.dart'; |
10 | 10 | |
11 | 11 | import '../../../models/course_section_entity.dart'; |
12 | 12 | import '../../../models/course_unit_entity.dart'; |
13 | +import '../../../utils/list_ext.dart'; | |
13 | 14 | |
14 | 15 | part 'section_event.dart'; |
15 | 16 | part 'section_state.dart'; |
16 | 17 | |
17 | 18 | class SectionBloc extends Bloc<SectionEvent, SectionState> { |
18 | 19 | |
19 | - CourseUnitEntity _courseUnitEntity; | |
20 | + PageController _pageController; | |
20 | 21 | |
21 | - CourseUnitEntity get courseUnitEntity => _courseUnitEntity; | |
22 | + PageController get pageController => _pageController; | |
22 | 23 | |
23 | - CourseUnitDetail _courseUnitDetail; | |
24 | + ///当前页索引 | |
25 | + int _currentPage = 0; | |
24 | 26 | |
25 | - CourseUnitDetail get courseUnitDetail => _courseUnitDetail; | |
27 | + int get currentPage => _currentPage; | |
26 | 28 | |
27 | - List<CourseSectionEntity>? _courseSectionDatas; | |
29 | + ScrollController _listController; | |
28 | 30 | |
29 | - List<CourseSectionEntity>? get courseSectionDatas => _courseSectionDatas; | |
31 | + ScrollController get listController => _listController; | |
32 | + | |
33 | + CourseUnitEntity _courseUnitEntity; | |
34 | + | |
35 | + CourseUnitEntity get courseUnitEntity => _courseUnitEntity; | |
36 | + | |
37 | + ///courseUnitId与课程环节列表的映射 | |
38 | + final Map<int, List<CourseSectionEntity>?> _courseSectionDatasMap = {}; | |
39 | + | |
40 | + Map<int, List<CourseSectionEntity>?> get courseSectionDatasMap => _courseSectionDatasMap; | |
30 | 41 | |
31 | 42 | CourseProcessEntity? _processEntity; |
32 | 43 | |
33 | 44 | CourseProcessEntity? get processEntity => _processEntity; |
34 | 45 | |
35 | - SectionBloc(this._courseUnitEntity, this._courseUnitDetail) : super(LessonInitial()) { | |
36 | - on<RequestDataEvent>(_requestData); | |
37 | - on<RequestExitClassEvent>(_requestExitClass); | |
46 | + SectionBloc(this._courseUnitEntity, this._currentPage, | |
47 | + this._pageController, this._listController) | |
48 | + : super(LessonInitial()) { | |
49 | + on<RequestDataEvent>(_requestSectionsData); | |
50 | + on<RequestEndClassEvent>(_requestEndClass); | |
38 | 51 | on<RequestEnterClassEvent>(_requestEnterClass); |
39 | 52 | on<RequestVideoLessonEvent>(_requestVideoLesson); |
53 | + on<CurrentUnitIndexChangeEvent>(_pageControllerChange); | |
40 | 54 | } |
41 | 55 | |
42 | - void _requestData(RequestDataEvent event, Emitter<SectionState> emitter) async { | |
56 | + void _requestSectionsData( | |
57 | + RequestDataEvent event, Emitter<SectionState> emitter) async { | |
43 | 58 | try { |
44 | 59 | await loading(() async { |
45 | - _courseSectionDatas = await LessonDao.courseSection(courseUnitId: _courseUnitDetail.id!); | |
60 | + _courseSectionDatasMap[event.courseUnitId] = | |
61 | + await LessonDao.courseSection(courseUnitId: event.courseUnitId); | |
46 | 62 | emitter(LessonDataLoadState()); |
47 | 63 | }); |
48 | 64 | } catch (e) { |
... | ... | @@ -52,34 +68,156 @@ class SectionBloc extends Bloc<SectionEvent, SectionState> { |
52 | 68 | } |
53 | 69 | } |
54 | 70 | |
55 | - void _requestVideoLesson(RequestVideoLessonEvent event, Emitter<SectionState> emitter) async { | |
71 | + void _requestVideoLesson( | |
72 | + RequestVideoLessonEvent event, Emitter<SectionState> emitter) async { | |
56 | 73 | try { |
57 | 74 | await loading(() async { |
58 | 75 | _processEntity = await ListenDao.process(event.courseLessonId); |
59 | - emitter(RequestVideoLessonState(event.courseLessonId,event.courseType)); | |
76 | + emitter( | |
77 | + RequestVideoLessonState(event.courseLessonId, event.courseType)); | |
60 | 78 | }); |
61 | 79 | } catch (e) { |
62 | 80 | if (e is ApiException) { |
63 | - showToast(e.message??'请求失败,请检查网络连接'); | |
81 | + showToast(e.message ?? '请求失败,请检查网络连接'); | |
64 | 82 | } |
65 | 83 | } |
66 | 84 | } |
67 | 85 | |
68 | - | |
69 | - void _requestEnterClass(RequestEnterClassEvent event,Emitter<SectionState> emitter) async { | |
86 | + void _requestEnterClass( | |
87 | + RequestEnterClassEvent event, Emitter<SectionState> emitter) async { | |
70 | 88 | try { |
71 | 89 | await loading(() async { |
72 | - await ListenDao.enterClass(event.courseLessonId); | |
73 | - emitter(RequestEnterClassState(event.courseLessonId,event.courseType)); | |
90 | + await ListenDao.enterClass(event.courseLessonId); | |
91 | + emitter(RequestEnterClassState(event.courseLessonId, event.courseType)); | |
74 | 92 | }); |
75 | 93 | } catch (e) { |
76 | 94 | if (e is ApiException) { |
77 | - showToast(e.message??'请求失败,请检查网络连接'); | |
95 | + showToast(e.message ?? '请求失败,请检查网络连接'); | |
78 | 96 | } |
79 | 97 | } |
80 | 98 | } |
81 | 99 | |
82 | - void _requestExitClass(RequestExitClassEvent event,Emitter<SectionState> emitter) async { | |
83 | - await ListenDao.exitClass(event.courseLessonId,event.currentStep,event.currentTime); | |
100 | + void _requestEndClass( | |
101 | + RequestEndClassEvent event, Emitter<SectionState> emitter) async { | |
102 | + if (event.isCompleted) { | |
103 | + await await ListenDao.endClass(event.courseLessonId, | |
104 | + currentStep: event.currentStep, | |
105 | + currentTime: event.currentTime); | |
106 | + } else { | |
107 | + await await ListenDao.exitClass(event.courseLessonId, | |
108 | + currentStep: event.currentStep, | |
109 | + currentTime: event.currentTime); | |
110 | + } | |
111 | + if (event.autoNextSection) { | |
112 | + final nextCourseSection = | |
113 | + await getNextCourseSection(int.parse(event.courseLessonId)); | |
114 | + if (nextCourseSection != null) { | |
115 | + ///进入课堂 | |
116 | + add(RequestEnterClassEvent(nextCourseSection.id.toString(), | |
117 | + nextCourseSection.courseType)); | |
118 | + } | |
119 | + } | |
120 | + } | |
121 | + | |
122 | + void _pageControllerChange( | |
123 | + CurrentUnitIndexChangeEvent event, Emitter<SectionState> emitter) async { | |
124 | + _currentPage = event.unitIndex; | |
125 | + emitter(CurrentPageIndexState()); | |
126 | + } | |
127 | + | |
128 | + ///未锁定的页(单元)数 | |
129 | + int unlockPageCount() { | |
130 | + return _courseUnitEntity.courseUnitVOList | |
131 | + ?.indexWhereOrNull((element) => element.lock == true) ?? | |
132 | + 1; | |
133 | + } | |
134 | + | |
135 | + ///当前页的课程详情 | |
136 | + CourseUnitDetail getCourseUnitDetail({int? pageIndex}) { | |
137 | + return _courseUnitEntity.courseUnitVOList![pageIndex ?? _currentPage]; | |
138 | + } | |
139 | + | |
140 | + ///根据courseLessonId查找对应的courseSection | |
141 | + CourseSectionEntity? findCourseSectionById(int courseLessonId) { | |
142 | + for (var entry in courseSectionDatasMap.entries) { | |
143 | + var sectionList = entry.value; | |
144 | + if (sectionList != null) { | |
145 | + for (var section in sectionList) { | |
146 | + if (section.id == courseLessonId) { | |
147 | + return section; | |
148 | + } | |
149 | + } | |
150 | + } | |
151 | + } | |
152 | + return null; | |
153 | + } | |
154 | + | |
155 | + ///根据sortOrder查找对应的courseSection | |
156 | + CourseSectionEntity? findCourseSectionBySort(int sortOrder) { | |
157 | + for (var entry in courseSectionDatasMap.entries) { | |
158 | + var sectionList = entry.value; | |
159 | + if (sectionList != null) { | |
160 | + for (var section in sectionList) { | |
161 | + if (section.sortOrder == sortOrder) { | |
162 | + return section; | |
163 | + } | |
164 | + } | |
165 | + } | |
166 | + } | |
167 | + return null; | |
168 | + } | |
169 | + | |
170 | + ///根据courseLessonId查找对应的CourseUnitDetail | |
171 | + CourseUnitDetail? findCourseUnitDetailById(int courseLessonId) { | |
172 | + final curCourseSectionEntity = findCourseSectionById(courseLessonId); | |
173 | + if (curCourseSectionEntity != null) { | |
174 | + final curCourseUnitDetail = _courseUnitEntity.courseUnitVOList?.firstWhere((element) => | |
175 | + element.id == curCourseSectionEntity.courseUnitId); | |
176 | + return curCourseUnitDetail; | |
177 | + } | |
178 | + return null; | |
179 | + } | |
180 | + | |
181 | + ///根据courseLessonId查找下一个courseSection | |
182 | + Future<CourseSectionEntity?> getNextCourseSection(int courseLessonId) async { | |
183 | + final curCourseSectionEntity = findCourseSectionById(courseLessonId); | |
184 | + final curSectionSort = curCourseSectionEntity?.sortOrder ?? 0; | |
185 | + final nextCourseSectionEntity = findCourseSectionBySort(curSectionSort + 1); | |
186 | + if (nextCourseSectionEntity != null) { | |
187 | + return nextCourseSectionEntity; | |
188 | + } else { | |
189 | + ///跨unit选lesson | |
190 | + final curCourseUnitDetail = findCourseUnitDetailById(courseLessonId); | |
191 | + if (curCourseUnitDetail != null) { | |
192 | + final nextCourseUnitDetail = _courseUnitEntity.courseUnitVOList | |
193 | + ?.firstWhere((element) => element.sortOrder == (curCourseUnitDetail.sortOrder! + 1)); | |
194 | + if (nextCourseUnitDetail != null) { | |
195 | + final courseUnitId = nextCourseUnitDetail.id!; | |
196 | + try { | |
197 | + await loading(() async { | |
198 | + _courseSectionDatasMap[courseUnitId] = | |
199 | + await LessonDao.courseSection(courseUnitId: courseUnitId); | |
200 | + emit(LessonDataLoadState()); | |
201 | + }); | |
202 | + _pageController.nextPage( | |
203 | + duration: const Duration(milliseconds: 500), | |
204 | + curve: Curves.ease, | |
205 | + ); | |
206 | + return _courseSectionDatasMap[courseUnitId]!.first; | |
207 | + } catch (e) { | |
208 | + if (e is ApiException) { | |
209 | + showToast(e.message.toString()); | |
210 | + } | |
211 | + return null; | |
212 | + } | |
213 | + } else { | |
214 | + ///最后一个unit了 | |
215 | + return null; | |
216 | + } | |
217 | + } else { | |
218 | + ///找不到对应的unitDetail,理论上不可能 | |
219 | + return null; | |
220 | + } | |
221 | + } | |
84 | 222 | } |
85 | 223 | } | ... | ... |
lib/pages/section/bloc/section_event.dart
... | ... | @@ -3,12 +3,17 @@ part of 'section_bloc.dart'; |
3 | 3 | @immutable |
4 | 4 | abstract class SectionEvent {} |
5 | 5 | |
6 | -class RequestDataEvent extends SectionEvent {} | |
6 | +class RequestDataEvent extends SectionEvent { | |
7 | + final int courseUnitId; | |
8 | + | |
9 | + RequestDataEvent(this.courseUnitId); | |
10 | +} | |
7 | 11 | |
8 | 12 | ///获取视频课程内容 |
9 | 13 | class RequestVideoLessonEvent extends SectionEvent { |
10 | 14 | final String courseLessonId; |
11 | 15 | final int courseType; |
16 | + | |
12 | 17 | RequestVideoLessonEvent(this.courseLessonId, this.courseType); |
13 | 18 | } |
14 | 19 | |
... | ... | @@ -16,13 +21,35 @@ class RequestVideoLessonEvent extends SectionEvent { |
16 | 21 | class RequestEnterClassEvent extends SectionEvent { |
17 | 22 | final String courseLessonId; |
18 | 23 | final int courseType; |
19 | - RequestEnterClassEvent(this.courseLessonId,this.courseType); | |
24 | + | |
25 | + RequestEnterClassEvent(this.courseLessonId, this.courseType); | |
20 | 26 | } |
21 | 27 | |
22 | -///退出课堂 | |
23 | -class RequestExitClassEvent extends SectionEvent { | |
28 | +///结束课堂 | |
29 | +class RequestEndClassEvent extends SectionEvent { | |
24 | 30 | final String courseLessonId; |
25 | - final String currentStep; | |
26 | - final String currentTime; | |
27 | - RequestExitClassEvent(this.courseLessonId,this.currentStep,this.currentTime); | |
31 | + | |
32 | + ///当前进展(进度类,比如练习、绘本) | |
33 | + final int? currentStep; | |
34 | + | |
35 | + ///当前时间(进度类,比如音视频) | |
36 | + final int? currentTime; | |
37 | + | |
38 | + ///课程环节是否完成(决定调结束接口还是退出接口) | |
39 | + final bool isCompleted; | |
40 | + | |
41 | + ///自动进入下一环节 | |
42 | + final bool autoNextSection; | |
43 | + | |
44 | + RequestEndClassEvent(this.courseLessonId, isCompleted, | |
45 | + {this.currentStep, this.currentTime, autoNextSection}) | |
46 | + : isCompleted = isCompleted ?? false, | |
47 | + autoNextSection = autoNextSection ?? false; | |
48 | +} | |
49 | + | |
50 | +///页面切换 | |
51 | +class CurrentUnitIndexChangeEvent extends SectionEvent { | |
52 | + final int unitIndex; | |
53 | + | |
54 | + CurrentUnitIndexChangeEvent(this.unitIndex); | |
28 | 55 | } | ... | ... |
lib/pages/section/bloc/section_state.dart
... | ... | @@ -10,11 +10,15 @@ class LessonDataLoadState extends SectionState {} |
10 | 10 | class RequestVideoLessonState extends SectionState { |
11 | 11 | final String courseLessonId; |
12 | 12 | final int type; |
13 | - RequestVideoLessonState(this.courseLessonId,this.type); | |
13 | + | |
14 | + RequestVideoLessonState(this.courseLessonId, this.type); | |
14 | 15 | } |
15 | 16 | |
16 | -class RequestEnterClassState extends SectionState{ | |
17 | +class RequestEnterClassState extends SectionState { | |
17 | 18 | final String courseLessonId; |
18 | 19 | final int courseType; |
19 | - RequestEnterClassState(this.courseLessonId,this.courseType); | |
20 | + | |
21 | + RequestEnterClassState(this.courseLessonId, this.courseType); | |
20 | 22 | } |
23 | + | |
24 | +class CurrentPageIndexState extends SectionState {} | ... | ... |
lib/pages/section/section_page.dart
1 | +import 'package:flutter/cupertino.dart'; | |
1 | 2 | import 'package:flutter/material.dart'; |
2 | 3 | import 'package:flutter_bloc/flutter_bloc.dart'; |
3 | 4 | import 'package:flutter_screenutil/flutter_screenutil.dart'; |
5 | +import 'package:nested_scroll_views/material.dart'; | |
4 | 6 | import 'package:wow_english/common/core/user_util.dart'; |
5 | 7 | import 'package:wow_english/common/extension/string_extension.dart'; |
6 | 8 | import 'package:wow_english/models/course_unit_entity.dart'; |
9 | +import 'package:wow_english/pages/section/section_type.dart'; | |
7 | 10 | import 'package:wow_english/pages/section/widgets/home_video_item.dart'; |
8 | 11 | import 'package:wow_english/pages/section/widgets/section_bouns_item.dart'; |
9 | 12 | import 'package:wow_english/pages/section/widgets/section_header_widget.dart'; |
... | ... | @@ -16,17 +19,22 @@ import 'courese_module_model.dart'; |
16 | 19 | |
17 | 20 | /// 环节列表页 |
18 | 21 | class SectionPage extends StatelessWidget { |
19 | - const SectionPage({super.key, required this.courseUnitEntity, required this.courseUnitDetail}); | |
22 | + const SectionPage( | |
23 | + {super.key, required this.courseUnitEntity, required this.courseUnitId}); | |
20 | 24 | |
21 | 25 | final CourseUnitEntity courseUnitEntity; |
22 | 26 | |
23 | 27 | /// unitId |
24 | - final CourseUnitDetail courseUnitDetail; | |
28 | + final int courseUnitId; | |
25 | 29 | |
26 | 30 | @override |
27 | 31 | Widget build(BuildContext context) { |
32 | + int initialPage = courseUnitEntity.courseUnitVOList | |
33 | + ?.indexWhere((element) => element.id == courseUnitId) ?? | |
34 | + 0; | |
28 | 35 | return BlocProvider( |
29 | - create: (context) => SectionBloc(courseUnitEntity, courseUnitDetail)..add(RequestDataEvent()), | |
36 | + create: (context) => SectionBloc(courseUnitEntity, initialPage, | |
37 | + PageController(initialPage: initialPage), ScrollController()), | |
30 | 38 | child: _SectionPageView(context), |
31 | 39 | ); |
32 | 40 | } |
... | ... | @@ -45,15 +53,15 @@ class _SectionPageView extends StatelessWidget { |
45 | 53 | if (state is RequestVideoLessonState) { |
46 | 54 | final videoUrl = bloc.processEntity?.videos?.videoUrl ?? ''; |
47 | 55 | var title = ''; |
48 | - if (state.type == 1) { | |
56 | + if (state.type == SectionType.song.value) { | |
49 | 57 | title = 'song'; |
50 | 58 | } |
51 | 59 | |
52 | - if (state.type == 2) { | |
60 | + if (state.type == SectionType.video.value) { | |
53 | 61 | title = 'video'; |
54 | 62 | } |
55 | 63 | |
56 | - if (state.type == 5) { | |
64 | + if (state.type == SectionType.bouns.value) { | |
57 | 65 | title = 'bonus'; |
58 | 66 | } |
59 | 67 | |
... | ... | @@ -63,22 +71,23 @@ class _SectionPageView extends StatelessWidget { |
63 | 71 | pushNamed(AppRouteName.lookVideo, arguments: { |
64 | 72 | 'videoUrl': videoUrl, |
65 | 73 | 'title': title, |
66 | - 'courseLessonId': state.courseLessonId | |
74 | + 'courseLessonId': state.courseLessonId, | |
75 | + 'isTopic': true | |
67 | 76 | }).then((value) { |
68 | 77 | if (value != null) { |
69 | - Map<String, String> dataMap = value as Map<String, String>; | |
70 | - bloc.add(RequestExitClassEvent( | |
71 | - dataMap['courseLessonId']!, | |
72 | - '0', | |
73 | - dataMap['currentTime']!, | |
74 | - )); | |
78 | + Map<String, dynamic> dataMap = value as Map<String, dynamic>; | |
79 | + bloc.add(RequestEndClassEvent( | |
80 | + dataMap['courseLessonId']!, dataMap['isCompleted'], | |
81 | + currentTime: dataMap['currentTime'], | |
82 | + autoNextSection: dataMap['nextSection'])); | |
75 | 83 | } |
76 | 84 | }); |
77 | 85 | return; |
78 | 86 | } |
79 | 87 | |
80 | 88 | if (state is RequestEnterClassState) { |
81 | - if (state.courseType != 3 && state.courseType != 4) { | |
89 | + if (state.courseType != SectionType.practice.value && | |
90 | + state.courseType != SectionType.pictureBook.value) { | |
82 | 91 | ///视频类型 |
83 | 92 | ///获取视频课程内容 |
84 | 93 | bloc.add(RequestVideoLessonEvent( |
... | ... | @@ -86,40 +95,46 @@ class _SectionPageView extends StatelessWidget { |
86 | 95 | return; |
87 | 96 | } |
88 | 97 | |
89 | - if (state.courseType == 4) { | |
98 | + if (state.courseType == SectionType.pictureBook.value) { | |
90 | 99 | //绘本 |
91 | 100 | pushNamed(AppRouteName.reading, |
92 | 101 | arguments: {'courseLessonId': state.courseLessonId}) |
93 | 102 | .then((value) { |
94 | 103 | if (value != null) { |
95 | - Map<String, String> dataMap = value as Map<String, String>; | |
96 | - bloc.add(RequestExitClassEvent( | |
97 | - dataMap['courseLessonId']!, dataMap['currentStep']!, '0')); | |
104 | + Map<String, dynamic> dataMap = value as Map<String, dynamic>; | |
105 | + bloc.add(RequestEndClassEvent( | |
106 | + dataMap['courseLessonId']!, | |
107 | + dataMap['isCompleted'], | |
108 | + currentStep: dataMap['currentStep'], | |
109 | + autoNextSection: dataMap['nextSection'], | |
110 | + )); | |
98 | 111 | } |
99 | 112 | }); |
100 | 113 | return; |
101 | 114 | } |
102 | 115 | |
103 | - if (state.courseType == 3) { | |
116 | + if (state.courseType == SectionType.practice.value) { | |
104 | 117 | //练习 |
105 | 118 | pushNamed(AppRouteName.topicPic, |
106 | 119 | arguments: {'courseLessonId': state.courseLessonId}) |
107 | 120 | .then((value) { |
108 | 121 | if (value != null) { |
109 | - Map<String, String> dataMap = value as Map<String, String>; | |
110 | - bloc.add(RequestExitClassEvent( | |
111 | - dataMap['courseLessonId']!, dataMap['currentStep']!, '0')); | |
122 | + Map<String, dynamic> dataMap = value as Map<String, dynamic>; | |
123 | + bloc.add(RequestEndClassEvent( | |
124 | + dataMap['courseLessonId']!, dataMap['isCompleted'], | |
125 | + currentStep: dataMap['currentStep'], | |
126 | + autoNextSection: dataMap['nextSection'])); | |
112 | 127 | } |
113 | 128 | }); |
114 | 129 | return; |
115 | 130 | } |
116 | 131 | } |
117 | 132 | }, |
118 | - child: _homeView(), | |
133 | + child: _sectionView(), | |
119 | 134 | ); |
120 | 135 | } |
121 | 136 | |
122 | - Widget _homeView() => | |
137 | + Widget _sectionView() => | |
123 | 138 | BlocBuilder<SectionBloc, SectionState>(builder: (context, state) { |
124 | 139 | final bloc = BlocProvider.of<SectionBloc>(context); |
125 | 140 | return Scaffold( |
... | ... | @@ -130,99 +145,71 @@ class _SectionPageView extends StatelessWidget { |
130 | 145 | mainAxisAlignment: MainAxisAlignment.spaceBetween, |
131 | 146 | children: [ |
132 | 147 | SectionHeaderWidget( |
133 | - title: bloc.courseUnitDetail.name, | |
148 | + title: bloc.getCourseUnitDetail().name, | |
134 | 149 | courseModuleCode: bloc.courseUnitEntity.courseModuleCode), |
135 | 150 | Expanded( |
136 | - child: ListView.builder( | |
137 | - itemCount: bloc.courseSectionDatas?.length ?? 0, | |
138 | - scrollDirection: Axis.horizontal, | |
139 | - itemBuilder: (BuildContext context, int index) { | |
140 | - CourseSectionEntity sectionData = | |
141 | - bloc.courseSectionDatas![index]; | |
142 | - if (sectionData.courseType == 5) { | |
143 | - //彩蛋 | |
144 | - return GestureDetector( | |
145 | - onTap: () { | |
146 | - if (!UserUtil.isLogined()) { | |
147 | - pushNamed(AppRouteName.login); | |
148 | - return; | |
149 | - } | |
150 | - if (sectionData.lock == true) { | |
151 | - showToast('当前课程暂未解锁'); | |
152 | - return; | |
153 | - } | |
154 | - | |
155 | - ///进入课堂 | |
156 | - bloc.add(RequestEnterClassEvent( | |
157 | - sectionData.id.toString(), sectionData.courseType)); | |
158 | - }, | |
159 | - child: SectionBoundsItem( | |
160 | - imageUrl: sectionData.coverUrl, | |
161 | - ), | |
162 | - ); | |
163 | - } else { | |
164 | - return GestureDetector( | |
165 | - onTap: () { | |
166 | - if (!UserUtil.isLogined()) { | |
167 | - pushNamed(AppRouteName.login); | |
168 | - return; | |
169 | - } | |
170 | - if (sectionData.lock == true) { | |
171 | - showToast('当前课程暂未解锁'); | |
172 | - return; | |
173 | - } | |
174 | - | |
175 | - ///进入课堂 | |
176 | - bloc.add(RequestEnterClassEvent( | |
177 | - sectionData.id.toString(), sectionData.courseType)); | |
178 | - }, | |
179 | - child: SectionVideoItem( | |
180 | - unitEntity: bloc.courseUnitEntity, | |
181 | - lessons: sectionData, | |
182 | - ), | |
183 | - ); | |
184 | - } | |
185 | - })), | |
186 | - // SafeArea( | |
187 | - // child: Padding( | |
188 | - // padding: EdgeInsets.symmetric(horizontal: 13.w), | |
189 | - // child: Row( | |
190 | - // mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
191 | - // children: [ | |
192 | - // SizedBox( | |
193 | - // height: 47.h, | |
194 | - // width: 80.w, | |
195 | - // ), | |
196 | - // Container( | |
197 | - // decoration: BoxDecoration( | |
198 | - // color: CourseModuleModel( | |
199 | - // bloc.courseUnitEntity.courseModuleCode ?? | |
200 | - // 'Phase-1') | |
201 | - // .color, | |
202 | - // borderRadius: BorderRadius.circular(14.5.r), | |
203 | - // ), | |
204 | - // padding: EdgeInsets.symmetric( | |
205 | - // vertical: 8.h, horizontal: 24.w), | |
206 | - // child: Text( | |
207 | - // '${(bloc.courseUnitEntity.nowStep ?? 0)}/${bloc.courseUnitEntity.total ?? 0}', | |
208 | - // style: TextStyle( | |
209 | - // color: Colors.white, fontSize: 12.sp), | |
210 | - // ), | |
211 | - // ), | |
212 | - // Image.asset( | |
213 | - // CourseModuleModel( | |
214 | - // bloc.courseUnitEntity.courseModuleCode ?? | |
215 | - // 'Phase-1') | |
216 | - // .courseModuleLogo | |
217 | - // .assetPng, | |
218 | - // height: 47.h, | |
219 | - // width: 80.w, | |
220 | - // // color: Colors.red, | |
221 | - // ), | |
222 | - // ], | |
223 | - // ), | |
224 | - // ), | |
225 | - // ) | |
151 | + child: Padding( | |
152 | + padding: EdgeInsets.symmetric(horizontal: 10.w), | |
153 | + child: OverflowBox( | |
154 | + child: NestedPageView.builder( | |
155 | + itemCount: bloc.unlockPageCount(), | |
156 | + controller: bloc.pageController, | |
157 | + onPageChanged: (int index) { | |
158 | + bloc.add(CurrentUnitIndexChangeEvent(index)); | |
159 | + }, | |
160 | + itemBuilder: (context, index) { | |
161 | + return ScrollConfiguration( | |
162 | + ///去掉 Android 上默认的边缘拖拽效果 | |
163 | + behavior: ScrollConfiguration.of(context) | |
164 | + .copyWith(overscroll: false), | |
165 | + child: _itemTransCard( | |
166 | + bloc.getCourseUnitDetail(pageIndex: index), | |
167 | + index, | |
168 | + context), | |
169 | + ); | |
170 | + }), | |
171 | + ), // 设置外部padding, | |
172 | + )), | |
173 | + SafeArea( | |
174 | + child: Padding( | |
175 | + padding: EdgeInsets.symmetric(horizontal: 13.w), | |
176 | + child: Row( | |
177 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, | |
178 | + children: [ | |
179 | + SizedBox( | |
180 | + height: 47.h, | |
181 | + width: 80.w, | |
182 | + ), | |
183 | + Container( | |
184 | + decoration: BoxDecoration( | |
185 | + color: CourseModuleModel( | |
186 | + bloc.courseUnitEntity.courseModuleCode ?? | |
187 | + 'Phase-1') | |
188 | + .color, | |
189 | + borderRadius: BorderRadius.circular(14.5.r), | |
190 | + ), | |
191 | + padding: EdgeInsets.symmetric( | |
192 | + vertical: 8.h, horizontal: 24.w), | |
193 | + child: Text( | |
194 | + '${bloc.currentPage + 1}/${bloc.unlockPageCount()}', | |
195 | + style: TextStyle( | |
196 | + color: Colors.white, fontSize: 12.sp), | |
197 | + ), | |
198 | + ), | |
199 | + Image.asset( | |
200 | + CourseModuleModel( | |
201 | + bloc.courseUnitEntity.courseModuleCode ?? | |
202 | + 'Phase-1') | |
203 | + .courseModuleLogo | |
204 | + .assetPng, | |
205 | + height: 47.h, | |
206 | + width: 80.w, | |
207 | + // color: Colors.red, | |
208 | + ), | |
209 | + ], | |
210 | + ), | |
211 | + ), | |
212 | + ) | |
226 | 213 | ], |
227 | 214 | ), |
228 | 215 | ), |
... | ... | @@ -230,3 +217,81 @@ class _SectionPageView extends StatelessWidget { |
230 | 217 | ); |
231 | 218 | }); |
232 | 219 | } |
220 | + | |
221 | +Widget _itemTransCard( | |
222 | + CourseUnitDetail courseUnitDetail, int pageIndex, BuildContext context) { | |
223 | + final bloc = BlocProvider.of<SectionBloc>(context); | |
224 | + List<CourseSectionEntity>? courseSectionEntities = | |
225 | + bloc.courseSectionDatasMap[courseUnitDetail.id]; | |
226 | + if (courseSectionEntities == null) { | |
227 | + bloc.add(RequestDataEvent(courseUnitDetail.id!)); | |
228 | + return Center( | |
229 | + child: Column( | |
230 | + mainAxisAlignment: MainAxisAlignment.center, | |
231 | + children: [ | |
232 | + const Text( | |
233 | + '暂无数据', | |
234 | + style: TextStyle(fontSize: 24.0), | |
235 | + ), | |
236 | + const SizedBox(height: 16.0), | |
237 | + // 间距 | |
238 | + CircularProgressIndicator( | |
239 | + color: CourseModuleModel( | |
240 | + bloc.courseUnitEntity.courseModuleCode ?? 'Phase-1') | |
241 | + .color), | |
242 | + // 加载动画 | |
243 | + ], | |
244 | + ), | |
245 | + ); | |
246 | + } else { | |
247 | + return NestedListView.builder( | |
248 | + itemCount: bloc.courseSectionDatasMap[courseUnitDetail.id]?.length ?? 0, | |
249 | + scrollDirection: Axis.horizontal, | |
250 | + itemBuilder: (BuildContext context, int index) { | |
251 | + CourseSectionEntity sectionData = courseSectionEntities[index]; | |
252 | + if (sectionData.courseType == SectionType.bouns.value) { | |
253 | + //彩蛋 | |
254 | + return GestureDetector( | |
255 | + onTap: () { | |
256 | + if (!UserUtil.isLogined()) { | |
257 | + pushNamed(AppRouteName.login); | |
258 | + return; | |
259 | + } | |
260 | + if (sectionData.lock == true) { | |
261 | + showToast('当前课程暂未解锁'); | |
262 | + return; | |
263 | + } | |
264 | + | |
265 | + ///进入课堂 | |
266 | + bloc.add(RequestEnterClassEvent( | |
267 | + sectionData.id.toString(), sectionData.courseType)); | |
268 | + }, | |
269 | + child: SectionBoundsItem( | |
270 | + imageUrl: sectionData.coverUrl, | |
271 | + ), | |
272 | + ); | |
273 | + } else { | |
274 | + return GestureDetector( | |
275 | + onTap: () { | |
276 | + if (!UserUtil.isLogined()) { | |
277 | + pushNamed(AppRouteName.login); | |
278 | + return; | |
279 | + } | |
280 | + if (sectionData.lock == true) { | |
281 | + showToast('当前课程暂未解锁'); | |
282 | + return; | |
283 | + } | |
284 | + | |
285 | + ///进入课堂 | |
286 | + bloc.add(RequestEnterClassEvent( | |
287 | + sectionData.id.toString(), sectionData.courseType)); | |
288 | + }, | |
289 | + child: SectionVideoItem( | |
290 | + unitEntity: bloc.courseUnitEntity, | |
291 | + lessons: sectionData, | |
292 | + ), | |
293 | + ); | |
294 | + } | |
295 | + }); | |
296 | + } | |
297 | +} | ... | ... |
lib/pages/section/section_type.dart
0 → 100644
1 | +///环节类型 | |
2 | +enum SectionType { | |
3 | + ///儿歌 | |
4 | + song, | |
5 | + | |
6 | + ///视频 | |
7 | + video, | |
8 | + | |
9 | + ///练习 | |
10 | + practice, | |
11 | + | |
12 | + ///绘本 | |
13 | + pictureBook, | |
14 | + | |
15 | + ///彩蛋 | |
16 | + bouns | |
17 | +} | |
18 | + | |
19 | +extension SectionTypeExtension on SectionType { | |
20 | + int get value { | |
21 | + switch (this) { | |
22 | + case SectionType.song: | |
23 | + return 1; | |
24 | + case SectionType.video: | |
25 | + return 2; | |
26 | + case SectionType.practice: | |
27 | + return 3; | |
28 | + case SectionType.pictureBook: | |
29 | + return 4; | |
30 | + case SectionType.bouns: | |
31 | + return 5; | |
32 | + default: | |
33 | + throw ArgumentError('Unknown section type'); | |
34 | + } | |
35 | + } | |
36 | +} | ... | ... |
lib/pages/section/subsection/base_section/bloc.dart
0 → 100644
1 | +import 'package:flutter/material.dart'; | |
2 | +import 'package:flutter_bloc/flutter_bloc.dart'; | |
3 | +import 'package:wow_english/common/extension/string_extension.dart'; | |
4 | + | |
5 | +import '../../../../route/route.dart'; | |
6 | +import 'event.dart'; | |
7 | +import 'state.dart'; | |
8 | + | |
9 | +abstract class BaseSectionBloc<E extends BaseSectionEvent, | |
10 | + S extends BaseSectionState> extends Bloc<E, S> { | |
11 | + BaseSectionBloc(super.initialState); | |
12 | + | |
13 | + bool isCompleteDialogShow = false; | |
14 | + | |
15 | + ///这里可以定义一些通用的逻辑 | |
16 | + void sectionComplete(final VoidCallback? nextSectionTap, | |
17 | + {VoidCallback? againSectionTap, BuildContext? context}) { | |
18 | + // 逻辑来标记步骤为已完成 | |
19 | + // 比如更新状态 | |
20 | + if (isCompleteDialogShow) { | |
21 | + return; | |
22 | + } | |
23 | + isCompleteDialogShow = true; | |
24 | + showDialog( | |
25 | + context: context ?? AppRouter.context, | |
26 | + barrierDismissible: false, | |
27 | + barrierColor: Colors.black54, | |
28 | + builder: (BuildContext context) { | |
29 | + return AlertDialog( | |
30 | + backgroundColor: Colors.transparent, | |
31 | + content: SizedBox( | |
32 | + width: double.infinity, // 宽度设置为无限,使其尽可能铺满屏幕 | |
33 | + height: MediaQuery.of(context).size.height * 0.6, // 高度设置为屏幕高度的60% | |
34 | + child: Row( | |
35 | + mainAxisAlignment: MainAxisAlignment.spaceBetween, // 图片之间分配空间 | |
36 | + children: <Widget>[ | |
37 | + Expanded( | |
38 | + flex: 1, | |
39 | + child: GestureDetector( | |
40 | + onTap: () { | |
41 | + popPage(); | |
42 | + againSectionTap!(); | |
43 | + }, | |
44 | + child: Image.asset('section_finish_again'.assetPng), | |
45 | + ), | |
46 | + ), | |
47 | + Expanded( | |
48 | + flex: 2, | |
49 | + child: Image.asset('section_finish_steve'.assetPng), | |
50 | + ), | |
51 | + Expanded( | |
52 | + flex: 1, | |
53 | + child: GestureDetector( | |
54 | + onTap: () { | |
55 | + popPage(); | |
56 | + nextSectionTap!(); | |
57 | + }, | |
58 | + child: Image.asset('section_finish_next'.assetPng), | |
59 | + ), | |
60 | + ), | |
61 | + ], | |
62 | + ), | |
63 | + ), | |
64 | + ); | |
65 | + }, | |
66 | + ).then((value) => isCompleteDialogShow = false); | |
67 | + } | |
68 | +} | ... | ... |
lib/pages/section/subsection/base_section/event.dart
0 → 100644
lib/pages/section/subsection/base_section/state.dart
0 → 100644
lib/pages/unit/view.dart
lib/pages/video/lookvideo/bloc/look_video_bloc.dart
1 | 1 | import 'package:flutter/cupertino.dart'; |
2 | 2 | import 'package:flutter_bloc/flutter_bloc.dart'; |
3 | 3 | import 'package:video_player/video_player.dart'; |
4 | +import 'package:wow_english/pages/section/subsection/base_section/bloc.dart'; | |
5 | +import 'package:wow_english/pages/section/subsection/base_section/event.dart'; | |
6 | +import 'package:wow_english/pages/section/subsection/base_section/state.dart'; | |
4 | 7 | |
5 | 8 | part 'look_video_event.dart'; |
6 | 9 | part 'look_video_state.dart'; |
7 | 10 | |
8 | -class LookVideoBloc extends Bloc<LookVideoEvent, LookVideoState> { | |
11 | +class LookVideoBloc extends BaseSectionBloc<BaseSectionEvent, BaseSectionState> { | |
9 | 12 | |
10 | 13 | VideoPlayerController? _controller; |
11 | 14 | |
12 | - LookVideoBloc() : super(LookVideoInitial()) { | |
15 | + final String? _videoUrl; | |
16 | + String? get videoUrl => _videoUrl; | |
17 | + final String? _typeTitle; | |
18 | + String? get typeTitle => _typeTitle; | |
19 | + final String? _courseLessonId; | |
20 | + String? get courseLessonId => _courseLessonId; | |
21 | + final bool _isTopic; | |
22 | + bool get isTopic => _isTopic; | |
23 | + | |
24 | + LookVideoBloc(this._videoUrl, this._typeTitle, this._courseLessonId, this._isTopic) : super(LookVideoInitial()) { | |
13 | 25 | on<LookVideoEvent>((event, emit) { |
14 | 26 | // TODO: implement event handler |
15 | 27 | }); |
28 | + on<SectionAgainEvent>((event, emit) { | |
29 | + emit(SectionAgainState()); | |
30 | + }); | |
16 | 31 | } |
17 | 32 | } | ... | ... |
lib/pages/video/lookvideo/bloc/look_video_event.dart
lib/pages/video/lookvideo/bloc/look_video_state.dart
lib/pages/video/lookvideo/look_video_page.dart
1 | 1 | import 'package:flutter/material.dart'; |
2 | +import 'package:flutter_bloc/flutter_bloc.dart'; | |
3 | +import 'package:wow_english/pages/section/subsection/base_section/state.dart'; | |
4 | +import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart'; | |
2 | 5 | import 'package:wow_english/pages/video/lookvideo/widgets/video_widget.dart'; |
3 | 6 | |
4 | -class LookVideoPage extends StatefulWidget { | |
5 | - const LookVideoPage({super.key, this.videoUrl, this.typeTitle, this.courseLessonId}); | |
7 | +class LookVideoPage extends StatelessWidget { | |
8 | + const LookVideoPage( | |
9 | + {super.key, this.videoUrl, this.typeTitle, this.courseLessonId, this.isTopic = false}); | |
6 | 10 | |
7 | 11 | final String? videoUrl; |
8 | 12 | final String? typeTitle; |
9 | 13 | final String? courseLessonId; |
14 | + final bool isTopic; | |
10 | 15 | |
11 | 16 | @override |
12 | - State<StatefulWidget> createState() { | |
13 | - return _LookVideoPageState(); | |
14 | - } | |
15 | -} | |
16 | - | |
17 | -class _LookVideoPageState extends State<LookVideoPage> { | |
18 | - @override | |
19 | 17 | Widget build(BuildContext context) { |
20 | - return Container( | |
21 | - color: Colors.white, | |
22 | - child: VideoWidget( | |
23 | - videoUrl: widget.videoUrl??'', | |
24 | - typeTitle: widget.typeTitle, | |
25 | - courseLessonId: widget.courseLessonId??'', | |
26 | - ), | |
18 | + return BlocProvider( | |
19 | + create: (BuildContext context) => LookVideoBloc(videoUrl, typeTitle, courseLessonId, isTopic), | |
20 | + child: Builder(builder: (context) => _buildPage(context)), | |
27 | 21 | ); |
28 | 22 | } |
29 | -} | |
30 | 23 | \ No newline at end of file |
24 | +} | |
25 | + | |
26 | +Widget _buildPage(BuildContext context) { | |
27 | + return BlocBuilder<LookVideoBloc, BaseSectionState>(builder: (context, state) { | |
28 | + final bloc = BlocProvider.of<LookVideoBloc>(context); | |
29 | + return Container( | |
30 | + color: Colors.white, | |
31 | + child: VideoWidget( | |
32 | + videoUrl: bloc.videoUrl ?? '', | |
33 | + typeTitle: bloc.typeTitle ?? '', | |
34 | + courseLessonId: bloc.courseLessonId ?? '', | |
35 | + isTopic: bloc.isTopic, | |
36 | + ) | |
37 | + ); | |
38 | + } | |
39 | + ); | |
40 | +} | ... | ... |
lib/pages/video/lookvideo/widgets/video_widget.dart
1 | 1 | import 'package:common_utils/common_utils.dart'; |
2 | 2 | import 'package:flutter/material.dart'; |
3 | +import 'package:flutter_bloc/flutter_bloc.dart'; | |
3 | 4 | import 'package:flutter_screenutil/flutter_screenutil.dart'; |
4 | 5 | import 'package:video_player/video_player.dart'; |
5 | 6 | import 'package:wow_english/common/extension/string_extension.dart'; |
7 | +import 'package:wow_english/pages/video/lookvideo/bloc/look_video_bloc.dart'; | |
6 | 8 | import 'package:wow_english/route/route.dart'; |
7 | 9 | |
10 | +import '../../../section/subsection/base_section/event.dart'; | |
11 | +import '../../../section/subsection/base_section/state.dart'; | |
8 | 12 | import 'video_opera_widget.dart'; |
9 | 13 | |
10 | 14 | class VideoWidget extends StatefulWidget { |
11 | - const VideoWidget({super.key, this.videoUrl = '',this.typeTitle, this.courseLessonId = ''}); | |
15 | + const VideoWidget({super.key, | |
16 | + this.videoUrl = '', | |
17 | + this.typeTitle, | |
18 | + this.courseLessonId = '', | |
19 | + this.isTopic = false}); | |
12 | 20 | |
13 | 21 | final String videoUrl; |
14 | 22 | final String? typeTitle; |
15 | 23 | final String courseLessonId; |
24 | + final bool isTopic; | |
16 | 25 | |
17 | 26 | @override |
18 | 27 | State<StatefulWidget> createState() { |
... | ... | @@ -30,39 +39,62 @@ class _VideoWidgetState extends State<VideoWidget> { |
30 | 39 | |
31 | 40 | String formatDuration(Duration duration) { |
32 | 41 | String hours = duration.inHours.toString().padLeft(2, '0'); |
33 | - String minutes = duration.inMinutes.remainder(60).toString().padLeft(2, '0'); | |
34 | - String seconds = duration.inSeconds.remainder(60).toString().padLeft(2, '0'); | |
42 | + String minutes = duration.inMinutes.remainder(60).toString().padLeft( | |
43 | + 2, '0'); | |
44 | + String seconds = duration.inSeconds.remainder(60).toString().padLeft( | |
45 | + 2, '0'); | |
35 | 46 | return "$hours:$minutes:$seconds"; |
36 | 47 | } |
37 | 48 | |
38 | 49 | void _addListener() { |
39 | 50 | _controller!.addListener(() { |
40 | - if(_controller!.value.isInitialized) { | |
51 | + if (_controller!.value.isInitialized) { | |
41 | 52 | if (_controller!.value.isPlaying) { |
42 | 53 | setState(() { |
43 | - double currentSecond = (_controller!.value.position.inMinutes.remainder(60)*60+_controller!.value.position.inSeconds.remainder(60)).toDouble(); | |
44 | - int totalSecond = _controller!.value.duration.inMinutes.remainder(60)*60+_controller!.value.duration.inSeconds.remainder(60); | |
54 | + double currentSecond = getCurrentPositionSeconds().toDouble(); | |
55 | + int totalSecond = | |
56 | + _controller!.value.duration.inMinutes.remainder(60) * 60 + | |
57 | + _controller!.value.duration.inSeconds.remainder(60); | |
45 | 58 | _currentTime = formatDuration(_controller!.value.position); |
46 | - _playDegree = currentSecond/totalSecond; | |
59 | + _playDegree = currentSecond / totalSecond; | |
47 | 60 | if (_playDegree > 1.0) { |
48 | 61 | _playDegree = 1.0; |
49 | 62 | } |
50 | - if(_playDegree < 0) { | |
63 | + if (_playDegree < 0) { | |
51 | 64 | _playDegree = 0.0; |
52 | 65 | } |
53 | 66 | }); |
67 | + } else if (widget.isTopic && | |
68 | + //受限于video_player库没有唯一的播放完成状态标识,发现isBuffering=false的时候暂时是安全唯一的 | |
69 | + _controller?.value.isBuffering == false && | |
70 | + _controller!.value.isCompleted && | |
71 | + _controller!.value.position.inSeconds == | |
72 | + _controller!.value.duration.inSeconds) { | |
73 | + final lookVideoBloc = context.read<LookVideoBloc>(); | |
74 | + lookVideoBloc.sectionComplete(() { | |
75 | + popPage(data: { | |
76 | + 'courseLessonId': widget.courseLessonId, | |
77 | + 'currentTime': getCurrentPositionSeconds(), | |
78 | + 'isCompleted': true, | |
79 | + 'nextSection': widget.isTopic | |
80 | + }); | |
81 | + } as VoidCallback, | |
82 | + againSectionTap: (() { | |
83 | + lookVideoBloc.add(SectionAgainEvent()); | |
84 | + }), context: context); | |
54 | 85 | } |
55 | 86 | } |
56 | 87 | }); |
57 | 88 | } |
58 | 89 | |
59 | 90 | //开始倒计时 |
60 | - void startTimer() { | |
61 | - if(timerUtil == null) { | |
62 | - timerUtil = TimerUtil(mInterval: 1000,mTotalTime: 1000*10); | |
91 | + void startTimer() { | |
92 | + if (timerUtil == null) { | |
93 | + timerUtil = TimerUtil(mInterval: 1000, mTotalTime: 1000 * 10); | |
63 | 94 | timerUtil!.setOnTimerTickCallback((int tick) { |
64 | 95 | double currentTick = tick / 1000; |
65 | - if (currentTick.toInt() == 0) {//倒计时结束 | |
96 | + if (currentTick.toInt() == 0) { | |
97 | + //倒计时结束 | |
66 | 98 | setState(() { |
67 | 99 | _hiddenTipView = true; |
68 | 100 | }); |
... | ... | @@ -89,8 +121,10 @@ class _VideoWidgetState extends State<VideoWidget> { |
89 | 121 | popPage(); |
90 | 122 | return; |
91 | 123 | } |
92 | - String currentTime = (_controller!.value.position.inMinutes.remainder(60)*60+_controller!.value.position.inSeconds.remainder(60)).toString(); | |
93 | - popPage(data:{'courseLessonId':widget.courseLessonId,'currentTime':currentTime}); | |
124 | + popPage(data: { | |
125 | + 'courseLessonId': widget.courseLessonId, | |
126 | + 'currentTime': getCurrentPositionSeconds(), | |
127 | + }); | |
94 | 128 | } |
95 | 129 | } else if (type == OperationType.playState) { |
96 | 130 | if (_controller!.value.isPlaying) { |
... | ... | @@ -98,9 +132,7 @@ class _VideoWidgetState extends State<VideoWidget> { |
98 | 132 | } else { |
99 | 133 | _controller!.play(); |
100 | 134 | } |
101 | - setState(() { | |
102 | - | |
103 | - }); | |
135 | + setState(() {}); | |
104 | 136 | } |
105 | 137 | } |
106 | 138 | |
... | ... | @@ -109,12 +141,12 @@ class _VideoWidgetState extends State<VideoWidget> { |
109 | 141 | super.initState(); |
110 | 142 | Uri uri = Uri.parse(widget.videoUrl); |
111 | 143 | _controller = VideoPlayerController.networkUrl(uri) |
112 | - ..initialize().then((_){ | |
144 | + ..initialize().then((_) { | |
113 | 145 | startTimer(); |
114 | 146 | setState(() { |
115 | 147 | _currentTime = formatDuration(_controller!.value.position); |
116 | 148 | _totalTime = formatDuration(_controller!.value.duration); |
117 | - _controller!.setLooping(true); | |
149 | + _controller!.setLooping(!widget.isTopic); | |
118 | 150 | _controller!.setVolume(100); |
119 | 151 | _controller!.play(); |
120 | 152 | }); |
... | ... | @@ -124,83 +156,95 @@ class _VideoWidgetState extends State<VideoWidget> { |
124 | 156 | |
125 | 157 | @override |
126 | 158 | Widget build(BuildContext context) { |
127 | - return GestureDetector( | |
128 | - onTap: () { | |
129 | - setState(() { | |
130 | - _hiddenTipView = !_hiddenTipView; | |
131 | - if(!_hiddenTipView) { | |
132 | - startTimer(); | |
133 | - } else { | |
134 | - if (timerUtil!.isActive()) { | |
135 | - cancelTimer(); | |
136 | - } | |
137 | - } | |
138 | - }); | |
159 | + return BlocListener<LookVideoBloc, BaseSectionState>( | |
160 | + listener: (context, state) async { | |
161 | + if (state is SectionAgainState) { | |
162 | + await _controller?.seekTo(Duration.zero); | |
163 | + _controller?.play(); | |
164 | + } | |
139 | 165 | }, |
140 | - onDoubleTap: () { | |
141 | - if(_controller!.value.isInitialized) { | |
142 | - if (_controller!.value.isPlaying) { | |
143 | - _controller!.pause(); | |
144 | - } else { | |
145 | - _controller!.play(); | |
146 | - } | |
166 | + child: GestureDetector( | |
167 | + onTap: () { | |
147 | 168 | setState(() { |
148 | - | |
169 | + _hiddenTipView = !_hiddenTipView; | |
170 | + if (!_hiddenTipView) { | |
171 | + startTimer(); | |
172 | + } else { | |
173 | + if (timerUtil!.isActive()) { | |
174 | + cancelTimer(); | |
175 | + } | |
176 | + } | |
149 | 177 | }); |
150 | - } | |
151 | - }, | |
152 | - child: Center( | |
153 | - child: _controller!.value.isInitialized ? Stack( | |
154 | - alignment: Alignment.center, | |
155 | - children: [ | |
156 | - SizedBox( | |
157 | - height: double.infinity, | |
158 | - width: double.infinity, | |
159 | - child: AspectRatio( | |
160 | - aspectRatio: _controller!.value.aspectRatio, | |
161 | - child: VideoPlayer(_controller!), | |
162 | - ), | |
163 | - ), | |
164 | - Offstage( | |
165 | - offstage: _hiddenTipView, | |
166 | - child: VideoOperaWidget( | |
167 | - title: widget.typeTitle??'song', | |
168 | - degree: _playDegree, | |
169 | - totalTime: _totalTime, | |
170 | - currentTime: _currentTime, | |
171 | - isPlay: _controller!.value.isPlaying, | |
172 | - actionEvent: (OperationType type) { | |
173 | - actionType(type); | |
174 | - }, | |
175 | - sliderChangeEvent: (double degree) { | |
176 | - int totalSecond = _controller!.value.duration.inMinutes.remainder(60)*60+_controller!.value.duration.inSeconds.remainder(60); | |
177 | - int positionSecond = (totalSecond * degree).toInt(); | |
178 | - _controller!.seekTo(Duration(seconds: positionSecond)); | |
179 | - }, | |
178 | + }, | |
179 | + onDoubleTap: () { | |
180 | + if (_controller!.value.isInitialized) { | |
181 | + if (_controller!.value.isPlaying) { | |
182 | + _controller!.pause(); | |
183 | + } else { | |
184 | + _controller!.play(); | |
185 | + } | |
186 | + setState(() {}); | |
187 | + } | |
188 | + }, | |
189 | + child: Center( | |
190 | + child: _controller!.value.isInitialized | |
191 | + ? Stack( | |
192 | + alignment: Alignment.center, | |
193 | + children: [ | |
194 | + SizedBox( | |
195 | + height: double.infinity, | |
196 | + width: double.infinity, | |
197 | + child: AspectRatio( | |
198 | + aspectRatio: _controller!.value.aspectRatio, | |
199 | + child: VideoPlayer(_controller!), | |
200 | + ), | |
180 | 201 | ), |
181 | - ), | |
182 | - Offstage( | |
183 | - offstage: _controller!.value.isPlaying, | |
184 | - child: IconButton( | |
185 | - onPressed: () { | |
186 | - _controller!.play(); | |
187 | - }, | |
188 | - icon: Image.asset( | |
189 | - 'video_stop'.assetPng, | |
190 | - width: 70.w, | |
191 | - height: 70.h, | |
202 | + Offstage( | |
203 | + offstage: _hiddenTipView, | |
204 | + child: VideoOperaWidget( | |
205 | + title: widget.typeTitle ?? 'song', | |
206 | + degree: _playDegree, | |
207 | + totalTime: _totalTime, | |
208 | + currentTime: _currentTime, | |
209 | + isPlay: _controller!.value.isPlaying, | |
210 | + actionEvent: (OperationType type) { | |
211 | + actionType(type); | |
212 | + }, | |
213 | + sliderChangeEvent: (double degree) { | |
214 | + int totalSecond = _controller! | |
215 | + .value.duration.inMinutes | |
216 | + .remainder(60) * | |
217 | + 60 + | |
218 | + _controller!.value.duration.inSeconds | |
219 | + .remainder(60); | |
220 | + int positionSecond = (totalSecond * degree).toInt(); | |
221 | + _controller! | |
222 | + .seekTo(Duration(seconds: positionSecond)); | |
223 | + }, | |
192 | 224 | ), |
193 | 225 | ), |
194 | - ) | |
195 | - ], | |
196 | - ): Container( | |
197 | - color: Colors.white, | |
198 | - child: Text( | |
199 | - '视频加载中....', | |
200 | - style: TextStyle( | |
201 | - fontSize: 20.sp, | |
202 | - color: Colors.black | |
203 | - ), | |
226 | + Offstage( | |
227 | + offstage: _controller!.value.isPlaying, | |
228 | + child: IconButton( | |
229 | + onPressed: () { | |
230 | + _controller!.play(); | |
231 | + }, | |
232 | + icon: Image.asset( | |
233 | + 'video_stop'.assetPng, | |
234 | + width: 70.w, | |
235 | + height: 70.h, | |
236 | + ), | |
237 | + ), | |
238 | + ) | |
239 | + ], | |
240 | + ) | |
241 | + : Container( | |
242 | + color: Colors.white, | |
243 | + child: const CircularProgressIndicator(), | |
244 | + // Text( | |
245 | + // '视频加载中....', | |
246 | + // style: TextStyle(fontSize: 20.sp, color: Colors.black), | |
247 | + // ), | |
204 | 248 | ), |
205 | 249 | ), |
206 | 250 | ), |
... | ... | @@ -217,4 +261,10 @@ class _VideoWidgetState extends State<VideoWidget> { |
217 | 261 | } |
218 | 262 | super.dispose(); |
219 | 263 | } |
264 | + | |
265 | + ///获取当前进度秒数 | |
266 | + int getCurrentPositionSeconds() { | |
267 | + return (_controller!.value.position.inMinutes.remainder(60) * 60 + | |
268 | + _controller!.value.position.inSeconds.remainder(60)); | |
269 | + } | |
220 | 270 | } | ... | ... |
lib/route/route.dart
... | ... | @@ -126,17 +126,17 @@ class AppRouter { |
126 | 126 | builder: (_) => UnitPage(courseModuleEntity: courseModuleEntity)); |
127 | 127 | case AppRouteName.courseSection: |
128 | 128 | CourseUnitEntity courseUnitEntity = CourseUnitEntity(); |
129 | - CourseUnitDetail courseUnitDetail = CourseUnitDetail(); | |
129 | + int courseUnitDetail = 0; | |
130 | 130 | if (settings.arguments != null) { |
131 | 131 | courseUnitEntity = (settings.arguments as Map) |
132 | 132 | .getOrNull('courseUnitEntity') as CourseUnitEntity; |
133 | 133 | courseUnitDetail = (settings.arguments as Map) |
134 | - .getOrNull('courseUnitDetail') as CourseUnitDetail; | |
134 | + .getOrNull('courseUnitId'); | |
135 | 135 | } |
136 | 136 | return CupertinoPageRoute( |
137 | 137 | builder: (_) => SectionPage( |
138 | 138 | courseUnitEntity: courseUnitEntity, |
139 | - courseUnitDetail: courseUnitDetail)); | |
139 | + courseUnitId: courseUnitDetail)); | |
140 | 140 | case AppRouteName.listen: |
141 | 141 | return CupertinoPageRoute(builder: (_) => const ListenPage()); |
142 | 142 | case AppRouteName.shop: |
... | ... | @@ -190,11 +190,14 @@ class AppRouter { |
190 | 190 | final title = (settings.arguments as Map)['title'] as String?; |
191 | 191 | final courseLessonId = |
192 | 192 | (settings.arguments as Map)['courseLessonId'] as String?; |
193 | + ///是否是课程内的视频环节,用于播放结束判断要不要再来一次以及下一环节用 | |
194 | + final isTopic = (settings.arguments as Map)['isTopic'] as bool? ?? false; | |
193 | 195 | return CupertinoPageRoute( |
194 | 196 | builder: (_) => LookVideoPage( |
195 | 197 | videoUrl: videoUrl, |
196 | 198 | typeTitle: title, |
197 | 199 | courseLessonId: courseLessonId, |
200 | + isTopic: isTopic, | |
198 | 201 | )); |
199 | 202 | /*case AppRouteName.setPwd: |
200 | 203 | case AppRouteName.setPwd: | ... | ... |
lib/utils/date_util.dart
0 → 100644
1 | + | |
2 | +///获取当前时间(单位:秒) | |
3 | +int getTimestampOfSecond() { | |
4 | + // 获取当前时间 | |
5 | + DateTime now = DateTime.now(); | |
6 | + | |
7 | + // 获取自Unix纪元以来的毫秒数 | |
8 | + int milliseconds = now.millisecondsSinceEpoch; | |
9 | + | |
10 | + // 将毫秒数转换为秒 | |
11 | + int seconds = milliseconds ~/ 1000; | |
12 | + | |
13 | + return seconds; | |
14 | +} | |
0 | 15 | \ No newline at end of file | ... | ... |
lib/utils/list_ext.dart
0 → 100644
pubspec.yaml
... | ... | @@ -16,10 +16,11 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev |
16 | 16 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html |
17 | 17 | # In Windows, build-name is used as the major, minor, and patch parts |
18 | 18 | # of the product and file versions while build-number is used as the build suffix. |
19 | -version: 1.0.3+3 | |
19 | +version: 1.0.4+4 | |
20 | 20 | |
21 | 21 | environment: |
22 | - sdk: '>=3.0.0 <4.0.0' | |
22 | + sdk: '>=3.2.0 <4.0.0' | |
23 | + flutter: '>=3.16.0' | |
23 | 24 | |
24 | 25 | # Dependencies specify other packages that your package needs in order to work. |
25 | 26 | # To automatically upgrade your package dependencies to the latest versions |
... | ... | @@ -90,7 +91,7 @@ dependencies: |
90 | 91 | # 富文本插件 https://pub.dev/packages/extended_text |
91 | 92 | extended_text: ^11.0.1 |
92 | 93 | # 视频播放 https://pub.dev/packages/video_player |
93 | - video_player: ^2.7.0 | |
94 | + video_player: ^2.8.6 | |
94 | 95 | # UI适配 https://pub.dev/packages/responsive_framework |
95 | 96 | responsive_framework: ^1.0.0 |
96 | 97 | # 音频播放 https://pub.dev/packages/audioplayers |
... | ... | @@ -111,6 +112,8 @@ dependencies: |
111 | 112 | umeng_common_sdk: ^1.2.7 |
112 | 113 | # 友盟APM https://pub-web.flutter-io.cn/packages/umeng_apm_sdk |
113 | 114 | umeng_apm_sdk: ^2.2.1 |
115 | + # 嵌套滚动 https://pub.dev/packages/nested_scroll_views | |
116 | + nested_scroll_views: ^0.0.10 | |
114 | 117 | |
115 | 118 | dev_dependencies: |
116 | 119 | build_runner: ^2.4.4 | ... | ... |