From 57305c5804ec999f1774bf0f00c418cd7c4f95f5 Mon Sep 17 00:00:00 2001 From: zhutao <1812073942@qq.com> Date: Fri, 28 Nov 2025 13:31:23 +0800 Subject: [PATCH] =?UTF-8?q?=E8=87=AA=E4=B9=A0=E5=AE=A4=E4=BC=98=E5=8C=96ok?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/build.gradle.kts | 47 +++- android/app/src/main/AndroidManifest.xml | 12 + android/app/src/main/res/xml/file_paths.xml | 7 + assets/image/version_bg.png | Bin 0 -> 48577 bytes build.dev.sh | 1 + build.prod.sh | 26 ++ key.jks | Bin 0 -> 2756 bytes lib/data/models/meeting_room_dto.dart | 78 ++++++ lib/pages/student/home/s_home_page.dart | 9 +- .../student/home/today/s_today_card.dart | 5 +- .../student/home/viewmodel/s_home_vm.dart | 4 +- .../student/home/widgets/feature_static.dart | 52 ---- lib/pages/student/home/widgets/tip_card.dart | 131 +++++++++++ .../student/room/controls/bottom_bar.dart | 59 +++-- lib/pages/student/room/controls/top_bar.dart | 101 ++++---- lib/pages/student/room/s_room_page.dart | 48 ++-- .../room/video/student_video_list.dart | 16 ++ .../student/room/video/teacher_video.dart | 113 +++++---- .../student/room/viewmodel/stu_room_vm.dart | 222 +++++++++++++----- .../student/room/widgets/status_view.dart | 99 ++++++++ lib/pages/teacher/home/t_home_page.dart | 6 + .../home/viewmodel/home_view_model.dart | 4 +- lib/pages/teacher/home/widgets/tip_card.dart | 143 +++++++++++ .../teacher/home/widgets/today_card.dart | 7 +- lib/pages/teacher/room/controls/top_bar.dart | 169 ++++++++++--- lib/pages/teacher/room/t_room_page.dart | 24 +- .../teacher/room/viewmodel/tch_room_vm.dart | 81 +++++-- .../teacher/room/widgets/content_view.dart | 96 +++++--- .../teacher/room/widgets/status_view.dart | 120 ++++------ .../teacher/room/widgets/student_item.dart | 35 ++- lib/request/api/common_api.dart | 18 ++ lib/request/api/room_api.dart | 6 +- lib/request/dto/common/qiu_token_dto.dart | 24 ++ lib/request/dto/common/version_dto.dart | 47 ++++ lib/request/dto/room/room_info_dto.dart | 81 ++++--- lib/request/dto/room/room_list_item_dto.dart | 51 ++++ lib/request/dto/room/room_type_dto.dart | 39 --- lib/request/dto/room/room_user_dto.dart | 2 +- lib/request/websocket/room_protocol.dart | 6 + lib/request/websocket/room_websocket.dart | 7 +- lib/utils/common.dart | 6 + lib/utils/time.dart | 42 +++- lib/utils/transfer/download.dart | 81 +++++++ lib/utils/transfer/upload.dart | 55 +++++ .../base/actionSheet/action_sheet.dart | 26 ++ .../base/actionSheet/action_sheet_ui.dart | 65 +++++ lib/widgets/base/actionSheet/type.dart | 18 ++ lib/widgets/base/button/index.dart | 10 +- .../common/preview/file_previewer.dart | 3 + lib/widgets/room/core/count_down_vm.dart | 93 ++++++++ lib/widgets/room/file_drawer.dart | 161 +++++++++++-- lib/widgets/room/other_widget.dart | 26 ++ lib/widgets/room/video_surface.dart | 74 +++--- lib/widgets/version/version_dialog.dart | 80 +++++++ lib/widgets/version/version_ui.dart | 174 ++++++++++++++ pubspec.lock | 180 +++++++++++++- pubspec.yaml | 7 +- 57 files changed, 2500 insertions(+), 597 deletions(-) create mode 100644 android/app/src/main/res/xml/file_paths.xml create mode 100644 assets/image/version_bg.png create mode 100644 build.dev.sh create mode 100644 build.prod.sh create mode 100644 key.jks create mode 100644 lib/data/models/meeting_room_dto.dart delete mode 100644 lib/pages/student/home/widgets/feature_static.dart create mode 100644 lib/pages/student/home/widgets/tip_card.dart create mode 100644 lib/pages/student/room/widgets/status_view.dart create mode 100644 lib/pages/teacher/home/widgets/tip_card.dart create mode 100644 lib/request/api/common_api.dart create mode 100644 lib/request/dto/common/qiu_token_dto.dart create mode 100644 lib/request/dto/common/version_dto.dart create mode 100644 lib/request/dto/room/room_list_item_dto.dart delete mode 100644 lib/request/dto/room/room_type_dto.dart create mode 100644 lib/utils/common.dart create mode 100644 lib/utils/transfer/download.dart create mode 100644 lib/utils/transfer/upload.dart create mode 100644 lib/widgets/base/actionSheet/action_sheet.dart create mode 100644 lib/widgets/base/actionSheet/action_sheet_ui.dart create mode 100644 lib/widgets/base/actionSheet/type.dart create mode 100644 lib/widgets/room/core/count_down_vm.dart create mode 100644 lib/widgets/room/other_widget.dart create mode 100644 lib/widgets/version/version_dialog.dart create mode 100644 lib/widgets/version/version_ui.dart diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 31b7d05..da5a16c 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -1,10 +1,18 @@ +import java.util.Properties +import java.io.FileInputStream +import java.time.LocalDateTime +import java.time.format.DateTimeFormatter + plugins { id("com.android.application") id("kotlin-android") // The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins. id("dev.flutter.flutter-gradle-plugin") } - +val keystorePropertiesFile = rootProject.file("key.properties") +val keystoreProperties = Properties().apply { + load(FileInputStream(keystorePropertiesFile)) +} android { namespace = "com.zkwl.xueguang" compileSdk = flutter.compileSdkVersion @@ -18,7 +26,14 @@ android { kotlinOptions { jvmTarget = JavaVersion.VERSION_11.toString() } - + packagingOptions { + pickFirsts += setOf( + "lib/x86/libaosl.so", + "lib/x86_64/libaosl.so", + "lib/armeabi-v7a/libaosl.so", + "lib/arm64-v8a/libaosl.so" + ) + } defaultConfig { // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html). applicationId = "com.zkwl.xueguang" @@ -28,13 +43,31 @@ android { targetSdk = flutter.targetSdkVersion versionCode = flutter.versionCode versionName = flutter.versionName + ndk { + abiFilters += listOf("arm64-v8a") // 只保留 arm64 + } + } + signingConfigs { + create("release") { + keyAlias = keystoreProperties["keyAlias"] as String + keyPassword = keystoreProperties["keyPassword"] as String + storeFile = file(keystoreProperties["storeFile"] as String) + storePassword = keystoreProperties["storePassword"] as String + } } - buildTypes { - release { - // TODO: Add your own signing config for the release build. - // Signing with the debug keys for now, so `flutter run --release` works. - signingConfig = signingConfigs.getByName("debug") + getByName("release") { + signingConfig = signingConfigs.getByName("release") + isMinifyEnabled = true // 开启混淆和代码压缩 + } + } + val env = project.findProperty("env")?.toString() ?: "dev" + val channel = project.findProperty("channel")?.toString() ?: "dev" + applicationVariants.all { + outputs.all { + if (this is com.android.build.gradle.internal.api.ApkVariantOutputImpl) { + outputFileName = "学光_${channel}_${versionName}.apk" + } } } } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa2d3e6..6854d03 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -47,6 +47,18 @@ + + + + + + + + diff --git a/assets/image/version_bg.png b/assets/image/version_bg.png new file mode 100644 index 0000000000000000000000000000000000000000..2d8c7e25943897a4fd5336f8a9150168546bfe2c GIT binary patch literal 48577 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 actLG)mq@B$ZfNoHp{AOiDoWYr<^KVtl7pH6 literal 0 HcmV?d00001 diff --git a/build.dev.sh b/build.dev.sh new file mode 100644 index 0000000..5573722 --- /dev/null +++ b/build.dev.sh @@ -0,0 +1 @@ +flutter build apk -Penv=dev diff --git a/build.prod.sh b/build.prod.sh new file mode 100644 index 0000000..4501893 --- /dev/null +++ b/build.prod.sh @@ -0,0 +1,26 @@ +#!/bin/bash + +#当前执行目录 +current_dir=$(pwd) +channelPath="$current_dir/build/app/outputs/apk/release" +mkdir -p "$channelPath" +rm -rf "$channelPath"/* +#定义环境变量和 Android 设备品牌 +declare -a APP_CHANNELS=("release") + +# 遍历每个环境配置并执行构建 +for index in "${APP_CHANNELS[@]}"; do + ENV=${APP_CHANNELS[$index]} + flutter build apk -Penv=prod -Pchannel=$ENV + # 检查构建是否成功 + if [ $? -ne 0 ]; then + echo "Build failed for ENV=$ENV" + exit 1 + fi + apkFile=$(find $(pwd)/build/app/outputs/apk/release/ -name "*.apk" -print -quit) + # 获取 .apk 文件的文件名 + apkFileName=$(basename "$apkFile") + mv $apkFile "$channelPath/$apkFileName" +done + +echo "All builds completed successfully." diff --git a/key.jks b/key.jks new file mode 100644 index 0000000000000000000000000000000000000000..2e7f4fa3b4f91a9200b84491b3f6cfe5c7626416 GIT binary patch literal 2756 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 literal 0 HcmV?d00001 diff --git a/lib/data/models/meeting_room_dto.dart b/lib/data/models/meeting_room_dto.dart new file mode 100644 index 0000000..d439023 --- /dev/null +++ b/lib/data/models/meeting_room_dto.dart @@ -0,0 +1,78 @@ +import 'package:app/request/dto/room/room_list_item_dto.dart'; + +class MeetingRoomDto { + int id; + final String roomName; + final String startTime; + final String endTime; + String actualStartTime; + int roomStatus; + String boardUuid; + + MeetingRoomDto({ + required this.id, + this.roomName = "", + this.startTime = "", + this.endTime = "", + this.actualStartTime = "", + this.roomStatus = 0, + this.boardUuid = "", + }); + + /// 根据 RoomListItemDto 创建 + MeetingRoomDto.fromRoomListItem(RoomListItemDto item) + : id = item.id, + roomName = item.roomName, + startTime = item.startTime, + endTime = item.endTime, + actualStartTime = "", + roomStatus = 0, + boardUuid = ""; + + /// 从 JSON 创建对象 + factory MeetingRoomDto.fromJson(Map json) { + return MeetingRoomDto( + id: json['id'] ?? 0, + roomName: json['room_name'] as String, + startTime: json['start_time'] as String, + endTime: json['end_time'] as String, + actualStartTime: json['actual_start_time'] ?? "", + roomStatus: json['room_status'] ?? 0, + boardUuid: json['board_uuid'] ?? "", + ); + } + + /// 转为 JSON + Map toJson() { + return { + 'id': id, + 'room_name': roomName, + 'start_time': startTime, + 'end_time': endTime, + 'actual_start_time': actualStartTime, + 'room_status': roomStatus, + 'board_uuid': boardUuid, + }; + } + + /// 复制对象,可修改部分字段 + MeetingRoomDto copyWith({ + int? id, + String? roomName, + String? startTime, + String? endTime, + String? actualStartTime, + int? roomStatus, + String? boardUuid, + }) { + return MeetingRoomDto( + id: id ?? this.id, + roomName: roomName ?? this.roomName, + startTime: startTime ?? this.startTime, + endTime: endTime ?? this.endTime, + actualStartTime: actualStartTime ?? this.actualStartTime, + roomStatus: roomStatus ?? this.roomStatus, + boardUuid: boardUuid ?? this.boardUuid, + ); + } +} diff --git a/lib/pages/student/home/s_home_page.dart b/lib/pages/student/home/s_home_page.dart index 3075fb7..3c3e2b0 100644 --- a/lib/pages/student/home/s_home_page.dart +++ b/lib/pages/student/home/s_home_page.dart @@ -1,9 +1,9 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; import 'package:app/pages/student/home/viewmodel/s_home_vm.dart'; -import 'package:app/request/api/room_api.dart'; -import 'package:app/request/dto/room/room_type_dto.dart'; +import 'package:app/widgets/version/version_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'widgets/tip_card.dart'; import 'today/s_today_card.dart'; import 'widgets/user_header.dart'; @@ -13,6 +13,7 @@ class SHomePage extends StatelessWidget { @override Widget build(BuildContext context) { + showUpdateDialog(context); return ChangeNotifierProvider( create: (_) => SHomeVm(), child: _HomeView(), @@ -21,7 +22,7 @@ class SHomePage extends StatelessWidget { } class _HomeView extends StatelessWidget { - const _HomeView({super.key}); + const _HomeView(); @override Widget build(BuildContext context) { @@ -35,6 +36,8 @@ class _HomeView extends StatelessWidget { padding: EdgeInsets.all(context.pagePadding), children: [ STodayCard(), + TipCard1(), + TipCard2() ], ), ), diff --git a/lib/pages/student/home/today/s_today_card.dart b/lib/pages/student/home/today/s_today_card.dart index 7b86f55..2e3607c 100644 --- a/lib/pages/student/home/today/s_today_card.dart +++ b/lib/pages/student/home/today/s_today_card.dart @@ -2,6 +2,7 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; import 'package:app/pages/student/home/viewmodel/s_home_vm.dart'; import 'package:app/router/route_paths.dart'; import 'package:app/utils/permission.dart'; +import 'package:app/utils/time.dart'; import 'package:app/widgets/base/button/index.dart'; import 'package:app/widgets/base/empty/index.dart'; import 'package:cached_network_image/cached_network_image.dart'; @@ -102,7 +103,7 @@ class _STodayCardState extends State { children: [ Text(vm.roomInfo?.teacherName ?? ""), Text( - vm.roomInfo?.teacherBackground ?? "", + vm.roomInfo?.teacherSchoolName ?? "", style: Theme.of(context).textTheme.labelLarge, ), ], @@ -121,7 +122,7 @@ class _STodayCardState extends State { ), InfoItem( label: "自习时长", - value: "${vm.roomMinutes} 分钟", + value: "${formatSeconds(vm.roomMinutes * 60, 'hh小时mm分钟')} ", icon: RemixIcons.timer_line, color: context.success, ), diff --git a/lib/pages/student/home/viewmodel/s_home_vm.dart b/lib/pages/student/home/viewmodel/s_home_vm.dart index f195ab3..fa1b88d 100644 --- a/lib/pages/student/home/viewmodel/s_home_vm.dart +++ b/lib/pages/student/home/viewmodel/s_home_vm.dart @@ -1,10 +1,10 @@ import 'package:app/request/api/room_api.dart'; -import 'package:app/request/dto/room/room_info_dto.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:app/utils/time.dart'; import 'package:flutter/cupertino.dart'; class SHomeVm extends ChangeNotifier { - RoomInfoDto? roomInfo; + RoomListItemDto ? roomInfo; bool loading = true; SHomeVm() { diff --git a/lib/pages/student/home/widgets/feature_static.dart b/lib/pages/student/home/widgets/feature_static.dart deleted file mode 100644 index 6399039..0000000 --- a/lib/pages/student/home/widgets/feature_static.dart +++ /dev/null @@ -1,52 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:remixicon/remixicon.dart'; - -class FeatureStatic extends StatelessWidget { - const FeatureStatic({super.key}); - - @override - Widget build(BuildContext context) { - final List items = [ - FeatureItem("视频陪学", "老师全程在线监督", RemixIcons.video_on_ai_line), - FeatureItem("举手提问", "实时互动解答疑惑", RemixIcons.hand), - FeatureItem("拍照题目", "快速上传问题截图", RemixIcons.camera_2_line), - FeatureItem("文件共享", "支持PDF等多种格式", RemixIcons.upload_2_line), - ]; - return Container( - margin: EdgeInsets.only(top: 15), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - spacing: 15, - children: [ - Text("核心功能", style: TextStyle(fontSize: 18)), - GridView.builder( - shrinkWrap: true, - physics: NeverScrollableScrollPhysics(), - gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 15, - crossAxisSpacing: 15, - mainAxisExtent: 120 - ), - itemBuilder: (_, index) { - return Container( - decoration: BoxDecoration( - color: Colors.white - ), - ); - }, - itemCount: items.length, - ), - ], - ), - ); - } -} - -class FeatureItem { - final String title; - final String desc; - final IconData icon; - - FeatureItem(this.title, this.desc, this.icon); -} diff --git a/lib/pages/student/home/widgets/tip_card.dart b/lib/pages/student/home/widgets/tip_card.dart new file mode 100644 index 0000000..7aa9442 --- /dev/null +++ b/lib/pages/student/home/widgets/tip_card.dart @@ -0,0 +1,131 @@ +import 'package:app/widgets/base/card/g_card.dart'; +import 'package:flutter/material.dart'; +import 'package:remixicon/remixicon.dart'; + +class TipCard1 extends StatelessWidget { + const TipCard1({super.key}); + + @override + Widget build(BuildContext context) { + final list = [ + { + "icon": RemixIcons.video_on_line, + "title": "视频陪学", + "desc": "老师全程在线监督", + }, + { + "icon": RemixIcons.hand, + "title": "举手提问", + "desc": "实时互动解答疑惑", + }, + { + "icon": RemixIcons.camera_ai_line, + "title": "拍照题目", + "desc": "快速上传问题截图", + }, + { + "icon": RemixIcons.file_upload_line, + "title": "文件共享", + "desc": "支持PDF等多种格式", + }, + ]; + return Container( + margin: EdgeInsets.only(top: 15), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + spacing: 10, + children: [ + Text("功能特色"), + Row( + spacing: 10, + children: list.map((t) { + final item = t as dynamic; + return Expanded( + child: GCard( + child: Column( + spacing: 5, + children: [ + Container( + padding: EdgeInsets.all(10), + decoration: BoxDecoration( + color: Theme.of(context).primaryColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + item['icon'], + color: Theme.of(context).primaryColor, + ), + ), + Text(item['title'],style: Theme.of(context).textTheme.bodySmall), + Text( + item['desc'], + style: Theme.of(context).textTheme.labelMedium, + ), + ], + ), + ), + ); + }).toList(), + ), + ], + ), + ); + } +} + + +class TipCard2 extends StatelessWidget { + const TipCard2({super.key}); + + @override + Widget build(BuildContext context) { + final tipList = [ + "请保持摄像头开启,确保学习状态可见", + "遇到问题可随时举手向老师提问", + "建议准备好学习资料,提高学习效率", + "自习期间请保持安静,避免打扰他人", + ]; + return Container( + margin: EdgeInsets.only(top: 15), + padding: EdgeInsets.all(15), + decoration: BoxDecoration( + color: Color(0xfffffbeb), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Color(0xfffee685), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.only(bottom: 10), + child: Text("温馨提示"), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, index) { + return Row( + spacing: 4, + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.black), + ), + Text( + tipList[index], + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ); + }, + separatorBuilder: (_, __) => SizedBox(height: 3), + itemCount: tipList.length, + ), + ], + ), + ); + } +} \ No newline at end of file diff --git a/lib/pages/student/room/controls/bottom_bar.dart b/lib/pages/student/room/controls/bottom_bar.dart index 1c7e64d..cde289e 100644 --- a/lib/pages/student/room/controls/bottom_bar.dart +++ b/lib/pages/student/room/controls/bottom_bar.dart @@ -1,12 +1,13 @@ -import 'package:app/pages/student/room/viewmodel/stu_room_vm.dart'; import 'package:app/widgets/room/file_drawer.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; - +import '../viewmodel/stu_room_vm.dart'; class BottomBar extends StatefulWidget { - const BottomBar({super.key}); + final void Function()? onTap; + + const BottomBar({super.key, this.onTap}); @override State createState() => _BottomBarState(); @@ -15,51 +16,77 @@ class BottomBar extends StatefulWidget { class _BottomBarState extends State { ///显示文件 void _handShowFile() { - showFileDialog(context); + final vm = context.read(); + if (vm.selfInfo == null) return; + showFileDialog( + context, + files: vm.selfInfo!.filesList, + onConfirm: (file) { + vm.uploadFile(file); + }, + ); } + @override Widget build(BuildContext context) { + final vm = context.watch(); + + if (vm.roomInfo.roomStatus != 1) { + return SizedBox(); + } return Container( decoration: BoxDecoration( color: Color(0xff232426), ), height: 70, child: Consumer( - builder: (context,vm,_) { + builder: (context, vm, _) { //摄像头开关 return Row( children: [ BarItem( title: "摄像头", - icon: vm.cameraOpen ? RemixIcons.video_on_fill : RemixIcons.video_off_fill, - isOff: !vm.cameraOpen, - onTap: vm.changeCameraSwitch, + icon: vm.cameraClose ? RemixIcons.video_off_fill : RemixIcons.video_on_fill, + isOff: vm.cameraClose, + onTap: () { + vm.changeCameraSwitch(value: vm.cameraClose); + }, ), BarItem( title: "麦克风", - icon: vm.micOpen ? RemixIcons.mic_fill : RemixIcons.mic_off_fill, - isOff: !vm.micOpen, - onTap: vm.changeMicSwitch, + icon: vm.micClose ? RemixIcons.mic_off_fill : RemixIcons.mic_fill, + isOff: vm.micClose, + onTap: () { + vm.changeMicSwitch(value: vm.micClose); + }, ), BarItem( title: "声音", - icon: vm.speakerOpen ? RemixIcons.volume_up_fill : RemixIcons.volume_mute_fill, - isOff: !vm.speakerOpen, - onTap: vm.changeSpeakerSwitch, + icon: vm.speakerClose + ? RemixIcons.volume_mute_fill + : RemixIcons.volume_up_fill, + isOff: vm.speakerClose, + onTap: () { + vm.changeSpeakerSwitch(value: vm.speakerClose); + }, ), BarItem( title: "举手", icon: RemixIcons.hand, + onTap: () { + vm.changeHandSwitch(); + widget.onTap?.call(); + }, ), BarItem( - title: "拍照", + title: "上传", icon: RemixIcons.upload_2_fill, onTap: _handShowFile, ), ], ); - } + }, ), ); } diff --git a/lib/pages/student/room/controls/top_bar.dart b/lib/pages/student/room/controls/top_bar.dart index 08bf12c..1d62322 100644 --- a/lib/pages/student/room/controls/top_bar.dart +++ b/lib/pages/student/room/controls/top_bar.dart @@ -1,13 +1,12 @@ -import 'dart:async'; - import 'package:app/utils/time.dart'; +import 'package:app/widgets/base/dialog/config_dialog.dart'; +import 'package:app/widgets/room/core/count_down_vm.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; -import '../viewmodel/stu_room_vm.dart'; - -class TopBar extends StatefulWidget implements PreferredSizeWidget { +class TopBar extends StatelessWidget implements PreferredSizeWidget { final bool showOther; final void Function()? onOther; @@ -17,67 +16,63 @@ class TopBar extends StatefulWidget implements PreferredSizeWidget { this.onOther, }); - @override - State createState() => _TopBarState(); - - @override - Size get preferredSize => const Size.fromHeight(kToolbarHeight); -} - -class _TopBarState extends State { - Timer? _timer; - int seconds = 0; - late DateTime startTime; - - @override - void initState() { - super.initState(); - final vm = context.read(); - startTime = parseTime(vm.roomInfo.startTime); - - _timer = Timer.periodic(const Duration(seconds: 1), (_) { - final diff = DateTime.now().difference(startTime).inSeconds; - setState(() { - seconds = diff < 0 ? 0 : diff; - }); - }); - } - - /// 你若想外面主动停,可以暴露这个方法 - void stopTimer() { - _timer?.cancel(); - } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - @override Widget build(BuildContext context) { - final vm = context.read(); - return AppBar( foregroundColor: Colors.white, titleTextStyle: const TextStyle(color: Colors.white, fontSize: 18), backgroundColor: const Color(0xff232426), centerTitle: true, - title: Column( - children: [ - Text(vm.roomInfo.roomName), - Text( - formatSeconds(seconds), - style: const TextStyle(fontSize: 12, color: Colors.white24), + leadingWidth: 100, + leading: Container( + padding: EdgeInsets.only(left: 10), + alignment: Alignment.centerLeft, + child: GestureDetector( + onTap: () { + showDialog( + context: context, + builder: (_) { + return ConfigDialog( + content: "请确认是否退出自习室", + onCancel: () { + context.pop(); + }, + onConfirm: () { + context.pop(); + context.pop(); + }, + ); + }, + ); + }, + child: Text( + "退出自习室", + style: TextStyle(color: Colors.red), ), - ], + ), + ), + title: Consumer( + builder: (context, vm, _) { + return Column( + children: [ + Text(vm.roomInfo!.roomName), + Text( + formatSeconds(vm.studyTime), + style: const TextStyle(fontSize: 12, color: Colors.white24), + ), + ], + ); + }, ), actions: [ IconButton( - onPressed: widget.onOther, - icon: Icon(widget.showOther ? RemixIcons.team_fill : RemixIcons.team_line), + onPressed: onOther, + icon: Icon(showOther ? RemixIcons.team_fill : RemixIcons.team_line), ), ], ); } + + @override + Size get preferredSize => const Size.fromHeight(kToolbarHeight); } diff --git a/lib/pages/student/room/s_room_page.dart b/lib/pages/student/room/s_room_page.dart index 868f64e..161e8ca 100644 --- a/lib/pages/student/room/s_room_page.dart +++ b/lib/pages/student/room/s_room_page.dart @@ -1,17 +1,18 @@ -import 'package:app/pages/student/room/viewmodel/stu_room_vm.dart'; +import 'package:app/pages/student/room/widgets/status_view.dart'; import 'package:app/providers/user_store.dart'; -import 'package:app/request/dto/room/room_info_dto.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:app/widgets/base/transition/slide_hide.dart'; +import 'package:app/widgets/room/core/count_down_vm.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'controls/bottom_bar.dart'; import 'controls/top_bar.dart'; import 'video/student_video_list.dart'; -import 'video/teacher_video.dart'; +import 'viewmodel/stu_room_vm.dart'; class SRoomPage extends StatefulWidget { - final RoomInfoDto roomInfo; + final RoomListItemDto roomInfo; const SRoomPage({super.key, required this.roomInfo}); @@ -36,29 +37,40 @@ class _SRoomPageState extends State { @override Widget build(BuildContext context) { UserStore userStore = context.read(); - return ChangeNotifierProvider( - create: (_) => StuRoomVM( - roomInfo: widget.roomInfo, - uid: userStore.userInfo!.id, - ), + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => StuRoomVM( + info: widget.roomInfo, + uid: userStore.userInfo!.id, + ), + ), + ChangeNotifierProxyProvider( + create: (_) => CountDownVM(), + update: (_, stuVM, countDownVM) { + countDownVM!.bind(stuVM.roomInfo); + return countDownVM; + }, + ), + ], child: Scaffold( body: Stack( children: [ - //底部控制显示 GestureDetector( onTap: _toggleOverlay, child: Container(color: Color(0xff2c3032)), ), - //老师视频画面 - TeacherVideo(), - + StatusView(), + //其他学生 Positioned( right: 0, top: 0, bottom: 0, - child: Visibility( - visible: _showOtherStudent, - child: StudentVideoList(), + child: IgnorePointer( + child: Visibility( + visible: _showOtherStudent, + child: StudentVideoList(), + ), ), ), @@ -87,7 +99,9 @@ class _SRoomPageState extends State { child: SlideHide( direction: SlideDirection.down, hide: !_controlsVisible, - child: BottomBar(), + child: BottomBar( + onTap: _toggleOverlay, + ), ), ), ], diff --git a/lib/pages/student/room/video/student_video_list.dart b/lib/pages/student/room/video/student_video_list.dart index 23f085e..4caff76 100644 --- a/lib/pages/student/room/video/student_video_list.dart +++ b/lib/pages/student/room/video/student_video_list.dart @@ -1,4 +1,6 @@ +import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:app/pages/student/room/viewmodel/stu_room_vm.dart'; +import 'package:app/widgets/room/video_surface.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -8,6 +10,9 @@ class StudentVideoList extends StatelessWidget { @override Widget build(BuildContext context) { final vm = context.watch(); + if (vm.roomInfo.roomStatus != 1) { + return SizedBox(); + } return SafeArea( child: Container( width: 250, @@ -26,6 +31,17 @@ class StudentVideoList extends StatelessWidget { color: Color(0xff373c3e), borderRadius: BorderRadius.circular(10), ), + child: VideoSurface( + user: item, + child: AgoraVideoView( + controller: VideoViewController( + rtcEngine: vm.engine!, + canvas: VideoCanvas( + uid: item.rtcUid, + ), + ), + ), + ), ), ), Positioned( diff --git a/lib/pages/student/room/video/teacher_video.dart b/lib/pages/student/room/video/teacher_video.dart index 81dc960..00ef54e 100644 --- a/lib/pages/student/room/video/teacher_video.dart +++ b/lib/pages/student/room/video/teacher_video.dart @@ -1,60 +1,83 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; +import 'package:app/widgets/base/dialog/config_dialog.dart'; +import 'package:app/widgets/room/other_widget.dart'; +import 'package:app/widgets/room/video_surface.dart'; import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; import '../viewmodel/stu_room_vm.dart'; -class TeacherVideo extends StatefulWidget { +class TeacherVideo extends StatelessWidget { const TeacherVideo({super.key}); - @override - State createState() => _TeacherVideoState(); -} - -class _TeacherVideoState extends State { @override Widget build(BuildContext context) { - final vm = context.read(); + final vm = context.watch(); final teacherInfo = vm.teacherInfo; - - ///没开始 - if (vm.roomStatus == 0) { - return _empty("自习室还没开始"); - } - - ///开始 - if (vm.roomStatus == 1 && vm.engine != null) { - if (teacherInfo == null) { - return _empty("老师不在自习室内"); - } - if (teacherInfo.online == 0) { - return _empty("老师掉线了,请耐心等待"); - } - return AgoraVideoView( - controller: VideoViewController( - rtcEngine: vm.engine!, - canvas: VideoCanvas( - uid: vm.teacherInfo!.userId, - ), - ), - ); - } - - ///结束 - if (vm.roomStatus == 2) { - return _empty("自习室已结束"); - } - return _empty("加载中"); - } - - Widget _empty(String title) { - return SafeArea( - child: Align( - child: Text( - title, - style: TextStyle(color: Colors.white), + return PopScope( + canPop: false, + onPopInvokedWithResult: (didPop, _) { + if (!didPop) { + showDialog( + context: context, + builder: (context) { + return ConfigDialog( + content: "是否退出自习室", + onCancel: () { + context.pop(); + }, + onConfirm: () { + context.pop(); + context.pop(); + }, + ); + }, + ); + } + }, + child: IgnorePointer( + child: Stack( + alignment: Alignment.bottomCenter, + children: [ + VideoSurface( + user: teacherInfo!, + child: AgoraVideoView( + controller: VideoViewController( + rtcEngine: vm.engine!, + canvas: VideoCanvas( + uid: teacherInfo.rtcUid, + ), + ), + ), + ), + Positioned( + top: 0, + left: 0, + child: Container( + width: 150, + color: Colors.black, + child: AspectRatio( + aspectRatio: 1 / 1.2, + child: AgoraVideoView( + controller: VideoViewController( + rtcEngine: vm.engine!, + canvas: const VideoCanvas(uid: 0), + ), + ), + ), + ), + ), + if (vm.selfInfo?.handup == 1) + Positioned( + bottom: 60, + child: HandRaiseButton( + onTap: vm.changeHandSwitch, + ), + ), + ], ), ), ); } -} +} \ No newline at end of file diff --git a/lib/pages/student/room/viewmodel/stu_room_vm.dart b/lib/pages/student/room/viewmodel/stu_room_vm.dart index 251c917..de30d16 100644 --- a/lib/pages/student/room/viewmodel/stu_room_vm.dart +++ b/lib/pages/student/room/viewmodel/stu_room_vm.dart @@ -2,24 +2,23 @@ import 'dart:async'; import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:app/config/config.dart'; -import 'package:app/providers/user_store.dart'; +import 'package:app/data/models/meeting_room_dto.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:app/request/dto/room/room_info_dto.dart'; -import 'package:app/request/dto/room/room_type_dto.dart'; import 'package:app/request/dto/room/room_user_dto.dart'; -import 'package:app/request/dto/room/rtc_token_dto.dart'; import 'package:app/request/websocket/room_protocol.dart'; import 'package:app/request/websocket/room_websocket.dart'; import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:logger/logger.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; Logger log = Logger(); class StuRoomVM extends ChangeNotifier { - ///房间信息 - final RoomInfoDto roomInfo; - - ///房间开启状态,0没开始,1进行中,2已结束 - int roomStatus = 0; + ///房间信息,状态0没开始,1进行中,2已结束 + late MeetingRoomDto roomInfo; ///其他学生列表,老师信息,自己信息 int uid; @@ -27,12 +26,18 @@ class StuRoomVM extends ChangeNotifier { RoomUserDto? teacherInfo; RoomUserDto? selfInfo; - ///本人的摄像头、麦克风、扬声器状态是否打开了 - bool get cameraOpen => selfInfo?.cameraStatus == 1; + // ///老师是否发送请求过来了,0关闭,1摄像头,2麦克风 + // bool cameraReq = false; + // bool micReq = false; - bool get micOpen => selfInfo?.microphoneStatus == 1; + ///本人的摄像头、麦克风、扬声器、举手状态是否关闭了 + bool get cameraClose => selfInfo?.cameraStatus == 0; - bool get speakerOpen => selfInfo?.speekerStatus == 1; + bool get micClose => selfInfo?.microphoneStatus == 0; + + bool get speakerClose => selfInfo?.speekerStatus == 0; + + bool get handClose => selfInfo?.handup == 0; ///ws管理 final RoomWebSocket _ws = RoomWebSocket(); @@ -43,12 +48,14 @@ class StuRoomVM extends ChangeNotifier { RtcEngine? get engine => _engine; - StuRoomVM({required this.roomInfo, required this.uid}) { + StuRoomVM({required RoomListItemDto info, required this.uid}) { + roomInfo = MeetingRoomDto.fromRoomListItem(info); _startRoom(); } ///初始化声网 Future _initRtc() async { + if (_engine != null) return; _engine = createAgoraRtcEngine(); //初始化 RtcEngine,设置频道场景为 channelProfileLiveBroadcasting(直播场景) await _engine!.initialize( @@ -57,27 +64,34 @@ class StuRoomVM extends ChangeNotifier { channelProfile: ChannelProfileType.channelProfileCommunication, ), ); - //启动视频模块 + _engine!.getUserInfoByUid(1); + // 启用视频模块 await _engine!.enableVideo(); - //加入频道 - await _engine!.joinChannel( - token: _ws.rtcToken!.token, - channelId: _ws.rtcToken!.channel, - uid: uid, - // uid: _ws.rtcToken!.uid, - options: ChannelMediaOptions( - // 自动订阅所有视频流 - autoSubscribeVideo: true, - // 自动订阅所有音频流 - autoSubscribeAudio: true, - // 发布摄像头采集的视频 - publishCameraTrack: true, - // 发布麦克风采集的音频 - publishMicrophoneTrack: true, - // 设置用户角色为 clientRoleBroadcaster(主播)或 clientRoleAudience(观众) - clientRoleType: ClientRoleType.clientRoleBroadcaster, - ), - ); + // 开启本地预览 + await _engine!.startPreview(); + + WakelockPlus.enable(); + final status = await _engine!.getConnectionState(); + if (status == ConnectionStateType.connectionStateDisconnected) { + //加入频道 + await _engine!.joinChannel( + token: _ws.rtcToken!.token, + channelId: _ws.rtcToken!.channel, + uid: _ws.rtcToken!.uid, + options: ChannelMediaOptions( + // 自动订阅所有视频流 + autoSubscribeVideo: true, + // 自动订阅所有音频流 + autoSubscribeAudio: true, + // 发布摄像头采集的视频 + publishCameraTrack: true, + // 发布麦克风采集的音频 + publishMicrophoneTrack: true, + // 设置用户角色为 clientRoleBroadcaster(主播)或 clientRoleAudience(观众) + clientRoleType: ClientRoleType.clientRoleBroadcaster, + ), + ); + } } ///开始链接房间 @@ -94,7 +108,30 @@ class StuRoomVM extends ChangeNotifier { if (msg.event == RoomEvent.changeUser) { final list = RoomUserDto.listFromJson(msg.data['user_list']); onStudentChange(list); - onRoomStartStatus(RoomTypeDto.fromJson(msg.data['room_info'])); + onRoomStartStatus(RoomInfoDto.fromJson(msg.data['room_info'])); + } else if (msg.event == RoomEvent.closeStudentCamera) { + changeCameraSwitch(fromServer: false, value: false); + } else if (msg.event == RoomEvent.closeStudentMic) { + changeMicSwitch(fromServer: false, value: false); + } else if ([ + RoomEvent.closeStudentSpeaker, + RoomEvent.openStudentSpeaker, + ].contains(msg.event)) { + //控制扬声器 + changeSpeakerSwitch( + value: msg.event == RoomEvent.openStudentSpeaker, + fromServer: false, + ); + } else if (msg.event == RoomEvent.openStudentMic) { + EasyLoading.showToast("老师请求打开麦克风"); + // 打开麦克风 + } else if (msg.event == RoomEvent.openStudentCamera) { + EasyLoading.showToast("老师请求打开摄像头"); + // 打开摄像头 + } else if (msg.event == RoomEvent.clearHandUp) { + changeHandSwitch(); + } else if (msg.event == RoomEvent.closeRoom) { + _closeRoom(); } }); } @@ -112,6 +149,10 @@ class StuRoomVM extends ChangeNotifier { newList.add(t); } else { selfInfo = t; + //同步声网的状态 + changeCameraSwitch(value: selfInfo!.cameraStatus == 1, fromServer: false); + changeMicSwitch(value: selfInfo!.microphoneStatus == 1, fromServer: false); + changeSpeakerSwitch(value: selfInfo!.speekerStatus == 1, fromServer: false); } } } @@ -120,52 +161,111 @@ class StuRoomVM extends ChangeNotifier { } ///设置房间开启状态 - void onRoomStartStatus(RoomTypeDto roomInfo) { - roomStatus = roomInfo.roomStatus; + void onRoomStartStatus(RoomInfoDto info) { + roomInfo = roomInfo.copyWith( + roomStatus: info.roomStatus, + actualStartTime: info.roomStartTime, + boardUuid: info.boardUuid, + ); + //开启摄像头 + if (roomInfo.roomStatus == 1) { + _initRtc(); + } notifyListeners(); } ///控制摄像头开关 - void changeCameraSwitch() { - bool isOpen = selfInfo!.cameraStatus == 1; - selfInfo!.cameraStatus = isOpen ? 0 : 1; - //发送指令 - _ws.send(RoomCommand.studentActon, { - "mute_type": "camera", - "is_mute": isOpen ? 1 : 0, - }); + /// - [value] 摄像头状态,true为开启,false为关闭 + /// - [fromServer] 发送指令给服务器,默认true + void changeCameraSwitch({ + required bool value, + bool fromServer = true, + }) { + //改变后的操作状态,true表示开,false关 + selfInfo!.cameraStatus = value ? 1 : 0; + // //发送指令 + if (fromServer) { + _ws.send(RoomCommand.studentActon, { + "mute_type": "camera", + "is_mute": value ? 0 : 1, + }); + } + _engine?.enableLocalVideo(value); notifyListeners(); } ///控制麦克风开关 - void changeMicSwitch() { - bool isOpen = selfInfo!.microphoneStatus == 1; - selfInfo!.microphoneStatus = isOpen ? 0 : 1; - print(selfInfo!.microphoneStatus); + /// - [value] 麦克风状态,true为开启,false为关闭 + /// - [fromServer] 默认为true,发送指令给服务器 + void changeMicSwitch({required bool value, bool fromServer = true}) { + selfInfo!.microphoneStatus = value ? 1 : 0; //发送指令 - _ws.send(RoomCommand.studentActon, { - "mute_type": "microphone", - "is_mute": isOpen ? 1 : 0, - }); + if (fromServer) { + _ws.send(RoomCommand.studentActon, { + "mute_type": "microphone", + "is_mute": value ? 0 : 1, + }); + } + _engine?.enableLocalAudio(value); notifyListeners(); } /// 控制扬声器开关 - void changeSpeakerSwitch() { - bool isOpen = selfInfo!.speekerStatus == 1; - selfInfo!.speekerStatus = isOpen ? 0 : 1; + /// - [value] 扬声器状态,true为开启,false为关闭 + /// - [fromServer] 默认为true,发送指令给服务器 + void changeSpeakerSwitch({required bool value, bool fromServer = true}) { + //操作后是否是开启状态 + selfInfo!.speekerStatus = value ? 1 : 0; //发送指令 - _ws.send(RoomCommand.studentActon, { - "mute_type": "speeker", - "is_mute": isOpen ? 1 : 0, - }); + if (fromServer) { + _ws.send(RoomCommand.studentActon, { + "mute_type": "speeker", + "is_mute": value ? 0 : 1, + }); + } + _engine?.muteAllRemoteAudioStreams(!value); notifyListeners(); } + ///控制举手 + void changeHandSwitch({bool fromServer = true}) { + bool nextOpen = handClose; + selfInfo!.handup = nextOpen ? 1 : 0; + if (fromServer) { + _ws.send(RoomCommand.handUp, { + 'is_handup': nextOpen ? 1 : 0, + }); + } + notifyListeners(); + } + + ///上传文件 + void uploadFile(List files) { + selfInfo?.filesList.addAll(files); + _ws.send(RoomCommand.uploadFile, { + "files": selfInfo!.filesList, + }); + } + + ///自习室关闭 + void _closeRoom() { + roomInfo.roomStatus = 2; + _dispose(); + notifyListeners(); + } + + ///销毁 + void _dispose() { + _engine?.leaveChannel(); + _engine?.release(); + _sub?.cancel(); + _ws.dispose(); + WakelockPlus.disable(); + } + @override void dispose() { super.dispose(); - _sub?.cancel(); - _ws.dispose(); + _dispose(); } } diff --git a/lib/pages/student/room/widgets/status_view.dart b/lib/pages/student/room/widgets/status_view.dart new file mode 100644 index 0000000..2889e82 --- /dev/null +++ b/lib/pages/student/room/widgets/status_view.dart @@ -0,0 +1,99 @@ +import 'package:app/utils/time.dart'; +import 'package:app/widgets/base/button/index.dart'; +import 'package:app/widgets/room/core/count_down_vm.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; + +import '../video/teacher_video.dart'; +import '../viewmodel/stu_room_vm.dart'; + +class StatusView extends StatelessWidget { + const StatusView({super.key}); + + @override + Widget build(BuildContext context) { + final vm = context.watch(); + final teacherInfo = vm.teacherInfo; + + ///没开始 + if (vm.roomInfo.roomStatus == 0) { + return Consumer( + builder: (_, countVM, __) { + if (countVM.canEnterRoom) { + return _empty("等待老师进入自习室"); + } else { + countVM.startStartCountdown(); + return Align( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "未到开播时间", + style: TextStyle(color: Colors.white), + ), + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: Text( + formatSeconds(countVM.startCountDown), + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ), + ); + } + }, + ); + } + + ///结束 + if (vm.roomInfo.roomStatus == 2) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.center, + spacing: 5, + children: [ + _empty("自习室已结束"), + SizedBox( + width: 120, + child: Button( + text: "返回首页", + onPressed: () { + context.pop(); + }, + ), + ), + ], + ); + } + + ///开始 + if (vm.roomInfo.roomStatus == 1 && vm.engine != null) { + if (teacherInfo == null) { + return _empty("老师不在自习室内"); + } + if (teacherInfo.online == 0) { + return _empty("老师暂时离开,请耐心等待"); + } + return TeacherVideo(); + } + + return _empty("加载中"); + } + + Widget _empty(String title) { + return SafeArea( + child: Align( + child: Text( + title, + style: TextStyle(color: Colors.white), + ), + ), + ); + } +} diff --git a/lib/pages/teacher/home/t_home_page.dart b/lib/pages/teacher/home/t_home_page.dart index 8fbeb49..82395f1 100644 --- a/lib/pages/teacher/home/t_home_page.dart +++ b/lib/pages/teacher/home/t_home_page.dart @@ -1,9 +1,11 @@ import 'package:app/config/theme/base/app_theme_ext.dart'; +import 'package:app/widgets/version/version_dialog.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'viewmodel/home_view_model.dart'; import 'widgets/header.dart'; +import 'widgets/tip_card.dart'; import 'widgets/today_card.dart'; class THomePage extends StatelessWidget { @@ -11,6 +13,7 @@ class THomePage extends StatelessWidget { @override Widget build(BuildContext context) { + showUpdateDialog(context); return ChangeNotifierProvider( create: (_) => HomeViewModel(), child: const _HomeView(), @@ -24,6 +27,7 @@ class _HomeView extends StatelessWidget { @override Widget build(BuildContext context) { final vm = context.read(); + return Scaffold( backgroundColor: Theme.of(context).colorScheme.surfaceContainer, appBar: Header(), @@ -36,6 +40,8 @@ class _HomeView extends StatelessWidget { ), children: [ TodayCard(), + TipCard1(), + TipCard2(), ], ), ), diff --git a/lib/pages/teacher/home/viewmodel/home_view_model.dart b/lib/pages/teacher/home/viewmodel/home_view_model.dart index bd9eb00..d6931c5 100644 --- a/lib/pages/teacher/home/viewmodel/home_view_model.dart +++ b/lib/pages/teacher/home/viewmodel/home_view_model.dart @@ -1,10 +1,10 @@ import 'package:app/request/api/room_api.dart'; -import 'package:app/request/dto/room/room_info_dto.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:app/utils/time.dart'; import 'package:flutter/material.dart'; class HomeViewModel extends ChangeNotifier { - RoomInfoDto? roomInfo; + RoomListItemDto ? roomInfo; bool loading = true; HomeViewModel() { diff --git a/lib/pages/teacher/home/widgets/tip_card.dart b/lib/pages/teacher/home/widgets/tip_card.dart new file mode 100644 index 0000000..acd0a56 --- /dev/null +++ b/lib/pages/teacher/home/widgets/tip_card.dart @@ -0,0 +1,143 @@ +import 'package:app/widgets/base/card/g_card.dart'; +import 'package:flutter/material.dart'; +import 'package:remixicon/remixicon.dart'; + +class TipCard1 extends StatelessWidget { + const TipCard1({super.key}); + + @override + Widget build(BuildContext context) { + final list = [ + { + "icon": RemixIcons.video_on_line, + "title": "实时视频互动", + "subtitle": "高清视频连接,随时与学生面对面交流", + }, + { + "icon": RemixIcons.file_list_line, + "title": "查看学生资料", + "subtitle": "查看学生上传的作业、题目和笔记", + }, + { + "icon": RemixIcons.message_line, + "title": "灵活管控", + "subtitle": "一键控制学生的视频、音频状态", + }, + { + "icon": RemixIcons.lightbulb_line, + "title": "白板演示", + "subtitle": "开启白板功能,为学生讲解疑难问题", + }, + ]; + return Container( + margin: EdgeInsets.only(top: 15), + child: Column( + spacing: 10, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("核心功能"), + GridView.builder( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 2, + mainAxisExtent: 80, + crossAxisSpacing: 15, + mainAxisSpacing: 15, + ), + itemBuilder: (_, index) { + final item = list[index] as dynamic; + return GCard( + child: Row( + spacing: 10, + children: [ + Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Theme.of(context).primaryColor, + borderRadius: BorderRadius.circular(10), + ), + child: Icon( + item["icon"], + color: Colors.white, + ), + ), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Text(item["title"]), + Text( + item["subtitle"], + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ), + ], + ), + ); + }, + itemCount: list.length, + ), + ], + ), + ); + } +} + +class TipCard2 extends StatelessWidget { + const TipCard2({super.key}); + + @override + Widget build(BuildContext context) { + final tipList = [ + "请确保网络环境良好,保证视频通话质量", + "建议提前5分钟进入自习室,准备教学材料", + "合理使用白板功能,帮助学生更好地理解知识点", + "关注每位学生的学习状态,及时提供帮助", + ]; + return Container( + margin: EdgeInsets.only(top: 15), + padding: EdgeInsets.all(15), + decoration: BoxDecoration( + color: Color(0xfffffbeb), + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: Color(0xfffee685), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Container( + margin: EdgeInsets.only(bottom: 10), + child: Text("温馨提示"), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (_, index) { + return Row( + spacing: 4, + children: [ + Container( + width: 5, + height: 5, + decoration: BoxDecoration(shape: BoxShape.circle, color: Colors.black), + ), + Text( + tipList[index], + style: Theme.of(context).textTheme.labelLarge, + ), + ], + ); + }, + separatorBuilder: (_, __) => SizedBox(height: 3), + itemCount: tipList.length, + ), + ], + ), + ); + } +} diff --git a/lib/pages/teacher/home/widgets/today_card.dart b/lib/pages/teacher/home/widgets/today_card.dart index accdc89..0df6fd3 100644 --- a/lib/pages/teacher/home/widgets/today_card.dart +++ b/lib/pages/teacher/home/widgets/today_card.dart @@ -1,11 +1,10 @@ import 'package:app/router/route_paths.dart'; import 'package:app/utils/permission.dart'; +import 'package:app/utils/time.dart'; import 'package:app/widgets/base/button/index.dart'; import 'package:app/widgets/base/card/g_card.dart'; import 'package:app/widgets/base/config/config.dart'; -import 'package:app/widgets/base/dialog/config_dialog.dart'; import 'package:app/widgets/base/empty/index.dart'; -import 'package:app/widgets/room/file_drawer.dart'; import 'package:flutter/material.dart'; import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:go_router/go_router.dart'; @@ -93,7 +92,7 @@ class _TodayCardState extends State { ), _item( title: "时长", - value: "${vm.roomMinutes} 分钟", + value: "${formatSeconds(vm.roomMinutes * 60, 'hh小时mm分钟')} ", icon: RemixIcons.book_open_line, color: Color(0xffac45fd), ), @@ -106,7 +105,7 @@ class _TodayCardState extends State { child: Button( text: vm.canEnterRoom ? "开始自习室" : "未到开始时间", type: ThemeType.success, - // disabled: !vm.canEnterRoom, + disabled: !vm.canEnterRoom, onPressed: _goToRoom, ), ), diff --git a/lib/pages/teacher/room/controls/top_bar.dart b/lib/pages/teacher/room/controls/top_bar.dart index 6237ac6..e66e0b8 100644 --- a/lib/pages/teacher/room/controls/top_bar.dart +++ b/lib/pages/teacher/room/controls/top_bar.dart @@ -1,43 +1,23 @@ +import 'package:app/utils/time.dart'; +import 'package:app/widgets/base/button/index.dart'; +import 'package:app/widgets/base/config/config.dart'; +import 'package:app/widgets/base/dialog/config_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; import 'package:remixicon/remixicon.dart'; +import '../../../../widgets/room/core/count_down_vm.dart'; +import '../viewmodel/tch_room_vm.dart'; +import '../viewmodel/type.dart'; + class TopBar extends StatelessWidget implements PreferredSizeWidget { const TopBar({super.key}); @override Widget build(BuildContext context) { - //标题子显示内容 - Widget infoItem({required String title, required IconData icon}) { - return Row( - spacing: 4, - children: [ - Icon(icon, color: Colors.white54, size: 14), - Text( - title, - style: TextStyle(fontSize: 12, color: Colors.white54), - ), - ], - ); - } - - //操作按钮 - Widget actionButton({required IconData icon, required String title}) { - return Container( - padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), - margin: EdgeInsets.only(right: 15), - decoration: BoxDecoration( - color: Color(0xff4a4f4f), - borderRadius: BorderRadius.circular(8), - ), - child: Row( - spacing: 8, - children: [ - Icon(icon, size: 16), - Text(title, style: TextStyle(fontSize: 14)), - ], - ), - ); - } + final vm = context.watch(); return AppBar( backgroundColor: Color(0xff373c3e), @@ -46,29 +26,144 @@ class TopBar extends StatelessWidget implements PreferredSizeWidget { spacing: 5, crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text("高三数学重置版", style: TextStyle(color: Colors.white, fontSize: 18)), + Text(vm.roomInfo.roomName, style: TextStyle(color: Colors.white, fontSize: 18)), Row( spacing: 15, children: [ - infoItem(title: "剩余 1小时23分钟", icon: RemixIcons.time_line), - infoItem(title: "8 名学生", icon: RemixIcons.group_line), + Consumer( + builder: (context, countVM, __) { + return _infoItem( + context, + title: "剩余 ${formatSeconds(countVM.endCountDown)}", + icon: RemixIcons.time_line, + ); + }, + ), + _infoItem( + context, + title: "${vm.students.length} 名学生", + icon: RemixIcons.group_line, + ), ], ), ], ), actions: [ - actionButton( + _actionButton( + context, icon: RemixIcons.video_on_ai_line, title: "关闭全部", + onPressed: () { + _closeAll(context, StudentAction.camera); + }, ), - actionButton( + _actionButton( + context, icon: RemixIcons.volume_up_line, title: "全部静音", + onPressed: () { + _closeAll(context, StudentAction.speaker); + }, ), + Container( + margin: EdgeInsets.only(right: 15), + child: Button( + text: "白板", + textStyle: TextStyle(fontSize: 14), + onPressed: (){}, + ), + ), + Consumer( + builder: (context, vm, _) { + if (vm.roomInfo.roomStatus != 1) { + return SizedBox(); + } + return Button( + type: ThemeType.danger, + textStyle: TextStyle(fontSize: 14), + text: "结束自习室", + onPressed: () { + showDialog( + context: context, + builder: (_) { + return ConfigDialog( + content: '是否结束自习室?结束后无法在进入', + onCancel: () { + context.pop(); + }, + onConfirm: () { + context.pop(); + vm.endRoom(); + EasyLoading.showToast("会议室已结束"); + }, + ); + }, + ); + }, + ); + }, + ), + SizedBox(width: 10), ], ); } + Widget _infoItem(BuildContext context, {required String title, required IconData icon}) { + return Row( + children: [ + Icon(icon, color: Colors.white54, size: 14), + SizedBox(width: 4), + Text(title, style: TextStyle(fontSize: 12, color: Colors.white54)), + ], + ); + } + + Widget _actionButton( + BuildContext context, { + required IconData icon, + required String title, + required VoidCallback onPressed, + }) { + return Container( + padding: EdgeInsets.symmetric(horizontal: 10, vertical: 5), + margin: EdgeInsets.only(right: 15), + decoration: BoxDecoration( + color: Color(0xff4a4f4f), + borderRadius: BorderRadius.circular(8), + ), + child: InkWell( + onTap: onPressed, + child: Row( + children: [ + Icon(icon, size: 16), + SizedBox(width: 8), + Text(title, style: TextStyle(fontSize: 14)), + ], + ), + ), + ); + } + + void _closeAll(BuildContext context, StudentAction action) { + final vm = context.read(); + String content = (action == StudentAction.camera) ? '是否关闭所有学生的摄像头?' : '是否关闭所有学生的扬声器?'; + + showDialog( + context: context, + builder: (_) { + return ConfigDialog( + content: content, + onCancel: () => context.pop(), + onConfirm: () { + context.pop(); + vm.closeAllStudentAction(action); + EasyLoading.showToast("操作已完成"); + }, + ); + }, + ); + } + @override Size get preferredSize => const Size.fromHeight(kToolbarHeight); } diff --git a/lib/pages/teacher/room/t_room_page.dart b/lib/pages/teacher/room/t_room_page.dart index 2f8fa4e..6dc9af4 100644 --- a/lib/pages/teacher/room/t_room_page.dart +++ b/lib/pages/teacher/room/t_room_page.dart @@ -1,4 +1,5 @@ -import 'package:app/request/dto/room/room_info_dto.dart'; +import 'package:app/widgets/room/core/count_down_vm.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'controls/top_bar.dart'; @@ -6,7 +7,7 @@ import 'widgets/status_view.dart'; import 'viewmodel/tch_room_vm.dart'; class TRoomPage extends StatefulWidget { - final RoomInfoDto roomInfo; + final RoomListItemDto roomInfo; const TRoomPage({ super.key, @@ -20,12 +21,19 @@ class TRoomPage extends StatefulWidget { class _TRoomPageState extends State { @override Widget build(BuildContext context) { - return ChangeNotifierProvider( - create: (BuildContext context) { - return TchRoomVM( - roomInfo: widget.roomInfo, - ); - }, + return MultiProvider( + providers: [ + ChangeNotifierProvider( + create: (_) => TchRoomVM(info: widget.roomInfo), + ), + ChangeNotifierProxyProvider( + create: (_) => CountDownVM(), + update: (_, tchVM, countDownVM) { + countDownVM!.bind(tchVM.roomInfo); + return countDownVM; + }, + ), + ], child: Scaffold( backgroundColor: Color(0xff2c3032), appBar: TopBar(), diff --git a/lib/pages/teacher/room/viewmodel/tch_room_vm.dart b/lib/pages/teacher/room/viewmodel/tch_room_vm.dart index 1b05457..9a673bf 100644 --- a/lib/pages/teacher/room/viewmodel/tch_room_vm.dart +++ b/lib/pages/teacher/room/viewmodel/tch_room_vm.dart @@ -1,7 +1,8 @@ import 'dart:async'; +import 'package:app/data/models/meeting_room_dto.dart'; +import 'package:app/request/dto/room/room_list_item_dto.dart'; import 'package:app/request/dto/room/room_info_dto.dart'; -import 'package:app/request/dto/room/room_type_dto.dart'; import 'package:app/request/dto/room/room_user_dto.dart'; import 'package:app/request/dto/room/rtc_token_dto.dart'; import 'package:app/request/websocket/room_protocol.dart'; @@ -13,18 +14,17 @@ import 'type.dart'; class TchRoomVM extends ChangeNotifier { TchRoomVM({ - required this.roomInfo, - String? start, + required RoomListItemDto info, }) { + roomInfo = MeetingRoomDto.fromRoomListItem(info).copyWith(roomStatus: -1); _startRoom(); } ///学生摄像头列表 List _students = []; - ///房间的基础信息 - final RoomInfoDto roomInfo; - int roomStatus = -1; // //-1加载中,0没开始,1进行中,2关闭 + ///房间的基础信息,其中状态-1加载中,0没开始,1进行中,2关闭 + late MeetingRoomDto roomInfo; ///老师选中的学生id int activeSId = 0; @@ -42,8 +42,6 @@ class TchRoomVM extends ChangeNotifier { ///websocket管理 final RoomWebSocket _ws = RoomWebSocket(); - - // bool wsConnected = false; // socket连接状态 StreamSubscription? _sub; RtcTokenDto? get rtcToken => _ws.rtcToken; @@ -61,8 +59,8 @@ class TchRoomVM extends ChangeNotifier { // 自习室人员变化 if (msg.event == RoomEvent.changeUser) { final list = RoomUserDto.listFromJson(msg.data['user_list']); - final room = RoomTypeDto.fromJson(msg.data['room_info']); - roomStatus = room.roomStatus; + final room = RoomInfoDto.fromJson(msg.data['room_info']); + _updateRoomInfo(room); onStudentChange(list); } else if ([ RoomEvent.openSpeaker, @@ -74,11 +72,26 @@ class TchRoomVM extends ChangeNotifier { RoomEvent.handUp, ].contains(msg.event)) { onSyncStudentItem(RoomUserDto.fromJson(msg.data)); + } else if (msg.event == RoomEvent.fileUploadComplete) { + updateStudentFile( + msg.data['user_id'], + (msg.data['flies'] as List).map((e) => e.toString()).toList(), + ); } }); notifyListeners(); } + ///更新房间信息 + void _updateRoomInfo(RoomInfoDto info) { + roomInfo = roomInfo.copyWith( + roomStatus: info.roomStatus, + actualStartTime: info.roomStartTime, + boardUuid: info.boardUuid, + ); + notifyListeners(); + } + ///自习室的开关 /// - [isOpen]: 是否开启 void toggleRoom({required bool isOpen}) { @@ -92,6 +105,7 @@ class TchRoomVM extends ChangeNotifier { ///学生选择 void selectStudent(int id) { activeSId = id; + clearHandUp(id); notifyListeners(); } @@ -114,20 +128,38 @@ class TchRoomVM extends ChangeNotifier { student.speekerStatus = isOpen ? 0 : 1; data['is_mute'] = isOpen ? 1 : 0; } else if (action == StudentAction.camera) { - //如果是摄像头,只能关 - if (student.cameraStatus == 0) return; + //如果是摄像头 + bool isOpen = student.cameraStatus == 1; student.cameraStatus = 0; - data['is_mute'] = 1; + data['is_mute'] = isOpen ? 1 : 0; } else if (action == StudentAction.microphone) { - //如果是麦克风,只能关 - if (student.microphoneStatus == 0) return; + //如果是麦克风 + bool isOpen = student.microphoneStatus == 1; student.microphoneStatus = 0; - data['is_mute'] = 1; + data['is_mute'] = isOpen ? 1 : 0; } notifyListeners(); _ws.send(RoomCommand.switchStudentCamera, data); } + ///关闭全部学生的摄像头或者扬声器 + void closeAllStudentAction(StudentAction action) { + _students.forEach((item) { + if (action == StudentAction.speaker) { + item.speekerStatus = 0; + } else if (action == StudentAction.camera) { + item.cameraStatus = 0; + } + }); + notifyListeners(); + Map data = { + 'target_user_id': "all", + "mute_type": action.value, + "is_mute": 1, + }; + _ws.send(RoomCommand.switchStudentCamera, data); + } + //清除全部学生举手,或者是指定 void clearHandUp(int? id) { Map data = {}; @@ -155,13 +187,28 @@ class TchRoomVM extends ChangeNotifier { /// 同步单个学生的最新状态 void onSyncStudentItem(RoomUserDto userInfo) { final index = _students.indexWhere((t) => t.userId == userInfo.userId); - print(userInfo.toString()); if (index != -1) { _students[index] = userInfo; notifyListeners(); } } + ///更新学生的文件 + void updateStudentFile(int uId, List files) { + final index = _students.indexWhere((t) => t.userId == uId); + if (index != -1) { + _students[index].filesList = files; + notifyListeners(); + } + } + + ///结束会议 + void endRoom() { + roomInfo = roomInfo.copyWith(roomStatus: 2); + _ws.send(RoomCommand.closeRoom); + notifyListeners(); + } + //销毁 @override void dispose() { diff --git a/lib/pages/teacher/room/widgets/content_view.dart b/lib/pages/teacher/room/widgets/content_view.dart index a5d6191..b99720f 100644 --- a/lib/pages/teacher/room/widgets/content_view.dart +++ b/lib/pages/teacher/room/widgets/content_view.dart @@ -1,8 +1,8 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:app/config/config.dart'; -import 'package:app/providers/user_store.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:wakelock_plus/wakelock_plus.dart'; import '../viewmodel/tch_room_vm.dart'; import 'student_item.dart'; @@ -27,11 +27,11 @@ class _ContentViewState extends State { @override void dispose() { super.dispose(); + WakelockPlus.disable(); _dispose(); } void _initRtc() async { - UserStore userStore = context.read(); final vm = context.read(); _engine = createAgoraRtcEngine(); //初始化 RtcEngine,设置频道场景为 channelProfileLiveBroadcasting(直播场景) @@ -41,39 +41,33 @@ class _ContentViewState extends State { channelProfile: ChannelProfileType.channelProfileCommunication, ), ); - //添加回调 - _engine!.registerEventHandler( - RtcEngineEventHandler( - // 成功加入频道回调 - onJoinChannelSuccess: (RtcConnection connection, int elapsed) { - setState(() {}); - }, - // 远端用户或主播加入当前频道回调 - onUserJoined: (RtcConnection connection, int remoteUid, int elapsed) {}, - // 远端用户或主播离开当前频道回调 - onUserOffline: (RtcConnection connection, int remoteUid, UserOfflineReasonType reason) {}, - ), - ); - //启动视频模块 + // 启用视频模块 await _engine!.enableVideo(); - //加入频道 - await _engine!.joinChannel( - token: vm.rtcToken!.token, - channelId: vm.rtcToken!.channel, - uid: userStore.userInfo!.id, - options: ChannelMediaOptions( - // 自动订阅所有视频流 - autoSubscribeVideo: true, - // 自动订阅所有音频流 - autoSubscribeAudio: true, - // 发布摄像头采集的视频 - publishCameraTrack: true, - // 发布麦克风采集的音频 - publishMicrophoneTrack: true, - // 设置用户角色为 clientRoleBroadcaster(主播)或 clientRoleAudience(观众) - clientRoleType: ClientRoleType.clientRoleBroadcaster, - ), - ); + // 开启本地预览 + await _engine!.startPreview(); + + final status = await _engine!.getConnectionState(); + WakelockPlus.enable(); + if (status == ConnectionStateType.connectionStateDisconnected) { + //加入频道 + await _engine!.joinChannel( + token: vm.rtcToken!.token, + channelId: vm.rtcToken!.channel, + uid: vm.rtcToken!.uid, + options: ChannelMediaOptions( + // 自动订阅所有视频流 + autoSubscribeVideo: true, + // 自动订阅所有音频流 + autoSubscribeAudio: true, + // 发布摄像头采集的视频 + publishCameraTrack: true, + // 发布麦克风采集的音频 + publishMicrophoneTrack: true, + // 设置用户角色为 clientRoleBroadcaster(主播)或 clientRoleAudience(观众) + clientRoleType: ClientRoleType.clientRoleBroadcaster, + ), + ); + } } //销毁 @@ -89,8 +83,11 @@ class _ContentViewState extends State { return Consumer( builder: (context, vm, _) { if (vm.students.isEmpty) { - return Center( - child: Text('准备中'), + return Align( + child: Text( + '学生还没入场', + style: TextStyle(color: Colors.white), + ), ); } //选中的学生 @@ -105,9 +102,30 @@ class _ContentViewState extends State { spacing: 15, children: [ Expanded( - child: StudentItem( - user: activeStudent, - engine: _engine, + child: Stack( + children: [ + StudentItem( + user: activeStudent, + engine: _engine, + ), + Positioned( + top: 0, + left: 0, + child: Container( + width: 150, + color: Colors.black, + child: AspectRatio( + aspectRatio: 1 / 1.2, + child: AgoraVideoView( + controller: VideoViewController( + rtcEngine: _engine!, + canvas: const VideoCanvas(uid: 0), + ), + ), + ), + ), + ), + ], ), ), SizedBox( diff --git a/lib/pages/teacher/room/widgets/status_view.dart b/lib/pages/teacher/room/widgets/status_view.dart index 5582c72..6ff7f25 100644 --- a/lib/pages/teacher/room/widgets/status_view.dart +++ b/lib/pages/teacher/room/widgets/status_view.dart @@ -1,11 +1,11 @@ -import 'dart:async'; - import 'package:app/utils/time.dart'; import 'package:app/widgets/base/dialog/config_dialog.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; +import '../../../../widgets/room/core/count_down_vm.dart'; import 'content_view.dart'; import '../viewmodel/tch_room_vm.dart'; @@ -17,40 +17,12 @@ class StatusView extends StatefulWidget { } class _StatusViewState extends State { - int _seconds = 0; - Timer? _timer; - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } - - void _startCountDown(DateTime startTime) { - // 避免重复计时器 - if (_timer != null) return; - - final now = DateTime.now(); - int diff = startTime.difference(now).inSeconds; - - if (diff <= 0) { - return; - } - setState(() { - _seconds = diff; - }); - - _timer = Timer.periodic(const Duration(seconds: 1), (timer) { - if (!mounted) return; - setState(() { - _seconds--; - }); - - if (_seconds <= 0) { - _timer?.cancel(); - _timer = null; - } - }); + void initState() { + super.initState(); + final countVM = context.read(); + countVM.removeListener(_onCountDownEnd); + countVM.addListener(_onCountDownEnd); } ///开播中返回拦截弹窗 @@ -72,55 +44,65 @@ class _StatusViewState extends State { ); } + ///监听会议室倒计时结束的时候 + void _onCountDownEnd() { + final countVM = context.read(); + if (countVM.endCountDown == 0) { + EasyLoading.showToast("自习室已到结束时间,请记得关闭会议室"); + countVM.removeListener(_onCountDownEnd); + } + } + @override Widget build(BuildContext context) { - final vm = context.watch(); + final tchVM = context.watch(); + var roomStatus = tchVM.roomInfo.roomStatus; /// 1. 未加载 - if (vm.roomStatus == -1) { + if (roomStatus == -1) { return const Align( child: Text("加载中", style: TextStyle(color: Colors.white)), ); } /// 2. 未开始的房间 - if (vm.roomStatus == 0) { - if (vm.canEnterRoom) { - // 到时间了 → 自动开播 - WidgetsBinding.instance.addPostFrameCallback((_) { - vm.toggleRoom(isOpen: true); - }); - } else { - // 没到时间 → 启动倒计时 - _startCountDown(parseTime(vm.roomInfo.startTime)); - } - - return Align( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text( - "未到开播时间,到点后自动开播", - style: TextStyle(color: Colors.white), - ), - Container( - margin: const EdgeInsets.symmetric(vertical: 10), - child: Text( - formatSeconds(_seconds), - style: const TextStyle( - color: Colors.white, - fontSize: 26, - fontWeight: FontWeight.bold, - ), + if (roomStatus == 0) { + return Consumer( + builder: (_, countVM, __) { + if (countVM.canEnterRoom) { + tchVM.toggleRoom(isOpen: true); + return SizedBox(); + } else { + countVM.startStartCountdown(); + return Align( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text( + "未到开播时间,到点后自动开播", + style: TextStyle(color: Colors.white), + ), + Container( + margin: const EdgeInsets.symmetric(vertical: 10), + child: Text( + formatSeconds(countVM.startCountDown), + style: const TextStyle( + color: Colors.white, + fontSize: 26, + fontWeight: FontWeight.bold, + ), + ), + ), + ], ), - ), - ], - ), + ); + } + }, ); } /// 3. 已开播 - if (vm.roomStatus == 1) { + if (roomStatus == 1) { return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { diff --git a/lib/pages/teacher/room/widgets/student_item.dart b/lib/pages/teacher/room/widgets/student_item.dart index 85d0c85..48cc88c 100644 --- a/lib/pages/teacher/room/widgets/student_item.dart +++ b/lib/pages/teacher/room/widgets/student_item.dart @@ -2,6 +2,7 @@ import 'package:agora_rtc_engine/agora_rtc_engine.dart'; import 'package:app/pages/teacher/room/viewmodel/type.dart'; import 'package:app/request/dto/room/room_user_dto.dart'; import 'package:app/widgets/room/file_drawer.dart'; +import 'package:app/widgets/room/other_widget.dart'; import 'package:app/widgets/room/video_surface.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; @@ -26,7 +27,11 @@ class StudentItem extends StatefulWidget { class _StudentItemState extends State { ///打开文件列表 void _openFileList() { - showFileDialog(context, isUpload: false); + showFileDialog( + context, + isUpload: false, + files: widget.user.filesList, + ); } @override @@ -40,7 +45,6 @@ class _StudentItemState extends State { ///声音是否开启 bool isSpeakerOpen = widget.user.speekerStatus == 1; - return ClipRRect( borderRadius: BorderRadius.circular(10), child: Container( @@ -51,15 +55,18 @@ class _StudentItemState extends State { child: SizedBox( width: double.infinity, child: Stack( + alignment: Alignment.bottomCenter, children: [ if (widget.engine != null) - AgoraVideoView( - controller: VideoViewController( - rtcEngine: widget.engine!, - canvas: VideoCanvas(uid: widget.user.rtcUid), + VideoSurface( + user: widget.user, + child: AgoraVideoView( + controller: VideoViewController( + rtcEngine: widget.engine!, + canvas: VideoCanvas(uid: widget.user.rtcUid), + ), ), ), - // VideoSurface(), Positioned( bottom: 0, left: 0, @@ -79,6 +86,8 @@ class _StudentItemState extends State { ), ), ), + + ///右上角选中 if (widget.user.userId != vm.activeSId) Positioned( right: 5, @@ -99,6 +108,17 @@ class _StudentItemState extends State { ), ), ), + + ///举手 + if (widget.user.handup == 1) + Positioned( + bottom: 40, + child: HandRaiseButton( + onTap: () { + vm.clearHandUp(widget.user.userId); + }, + ), + ), ], ), ), @@ -106,6 +126,7 @@ class _StudentItemState extends State { ColoredBox( color: Color(0xFF232426), child: Row( + spacing: 1, children: [ _actionItem( icon: isCameraOpen ? RemixIcons.video_on_fill : RemixIcons.video_off_fill, diff --git a/lib/request/api/common_api.dart b/lib/request/api/common_api.dart new file mode 100644 index 0000000..0bb5ee2 --- /dev/null +++ b/lib/request/api/common_api.dart @@ -0,0 +1,18 @@ +import '../dto/common/qiu_token_dto.dart'; +import '../dto/common/version_dto.dart'; +import '../network/request.dart'; + +///获取七牛token +/// - [fileKey]: 文件key +Future getQiuTokenApi(String fileKey) async { + var response = await Request().get("/files/get_qiniu_upload_token", { + "file_key": fileKey, + }); + return QiuTokenDto.fromJson(response); +} + +///获取APP最新版本 +Future getAppVersionApi() async { + var response = await Request().get("/get_latest_version"); + return VersionDto.fromJson(response); +} diff --git a/lib/request/api/room_api.dart b/lib/request/api/room_api.dart index 46dad16..3a79f25 100644 --- a/lib/request/api/room_api.dart +++ b/lib/request/api/room_api.dart @@ -1,12 +1,12 @@ import 'package:app/request/dto/room/rtc_token_dto.dart'; import 'package:app/request/network/request.dart'; -import '../dto/room/room_info_dto.dart'; +import '../dto/room/room_list_item_dto.dart'; /// 获取房间列表 -Future> getRoomListApi() async { +Future> getRoomListApi() async { var res = await Request().get('/study_room/get_study_room_list'); - return List.from(res.map((x) => RoomInfoDto.fromJson(x))); + return List.from(res.map((x) => RoomListItemDto .fromJson(x))); } ///获取自习室的websocket令牌 diff --git a/lib/request/dto/common/qiu_token_dto.dart b/lib/request/dto/common/qiu_token_dto.dart new file mode 100644 index 0000000..c6d438e --- /dev/null +++ b/lib/request/dto/common/qiu_token_dto.dart @@ -0,0 +1,24 @@ +class QiuTokenDto { + String? uploadUrl; + String? upToken; + String? fileKey; + String? domain; + + QiuTokenDto({this.uploadUrl, this.upToken, this.fileKey, this.domain}); + + Map toJson() { + final map = {}; + map["upload_url"] = uploadUrl; + map["up_token"] = upToken; + map["file_key"] = fileKey; + map["domain"] = domain; + return map; + } + + QiuTokenDto.fromJson(dynamic json) { + uploadUrl = json["upload_url"] ?? ""; + upToken = json["up_token"] ?? ""; + fileKey = json["file_key"] ?? ""; + domain = json["domain"] ?? ""; + } +} diff --git a/lib/request/dto/common/version_dto.dart b/lib/request/dto/common/version_dto.dart new file mode 100644 index 0000000..33ad5b3 --- /dev/null +++ b/lib/request/dto/common/version_dto.dart @@ -0,0 +1,47 @@ +class VersionDto { + VersionDto({ + required this.latestVersion, + required this.updatedAt, + required this.downloadUrl, + required this.updateContent, + required this.createdAt, + required this.lowVersion, + required this.id, + required this.downloadSize, + required this.platform, + }); + + String latestVersion; + DateTime updatedAt; + String downloadUrl; + List updateContent; + DateTime createdAt; + String lowVersion; + int id; + String downloadSize; + int platform; + + factory VersionDto.fromJson(Map json) => VersionDto( + latestVersion: json["latest_version"], + updatedAt: DateTime.parse(json["updated_at"]), + downloadUrl: json["download_url"], + updateContent: List.from(json["update_content"].map((x) => x)), + createdAt: DateTime.parse(json["created_at"]), + lowVersion: json["low_version"], + id: json["id"], + downloadSize: json["download_size"], + platform: json["platform"], + ); + + Map toJson() => { + "latest_version": latestVersion, + "updated_at": updatedAt.toIso8601String(), + "download_url": downloadUrl, + "update_content": List.from(updateContent.map((x) => x)), + "created_at": createdAt.toIso8601String(), + "low_version": lowVersion, + "id": id, + "download_size": downloadSize, + "platform": platform, + }; +} diff --git a/lib/request/dto/room/room_info_dto.dart b/lib/request/dto/room/room_info_dto.dart index 16655ef..8599dd8 100644 --- a/lib/request/dto/room/room_info_dto.dart +++ b/lib/request/dto/room/room_info_dto.dart @@ -1,41 +1,52 @@ class RoomInfoDto { + final int studyRoomId; + final int teacherId; + final int teacherRtcUid; + final String teacherWsClientId; + final int roomStatus; + final String dataType; + final String roomStartTime; + final String roomEndTime; + final String boardUuid; + + RoomInfoDto({ - required this.teacherBackground, - required this.teacherAvatar, - required this.roomName, - required this.startTime, - required this.teacherName, - required this.endTime, - required this.id, + required this.studyRoomId, + required this.teacherId, + required this.teacherRtcUid, + required this.teacherWsClientId, + required this.roomStatus, + required this.dataType, + required this.roomStartTime, + required this.roomEndTime, + required this.boardUuid, }); - String teacherBackground; - String teacherAvatar; - String roomName; - String startTime; - String teacherName; - String endTime; - int id; + Map toJson() { + final map = {}; + map["study_room_id"] = studyRoomId; + map["teacher_id"] = teacherId; + map["teacher_rtc_uid"] = teacherRtcUid; + map["teacher_ws_client_id"] = teacherWsClientId; + map["room_status"] = roomStatus; + map["data_type"] = dataType; + map["room_start_time"] = roomStartTime; + map["room_end_time"] = roomEndTime; + map["whiteboard_uuid"] = boardUuid; + return map; + } - factory RoomInfoDto.fromJson(Map json) => - RoomInfoDto( - teacherBackground: json["teacher_background"], - teacherAvatar: json["teacher_avatar"], - roomName: json["room_name"], - startTime: json["start_time"], - teacherName: json["teacher_name"], - endTime: json["end_time"], - id: json["id"], - ); - - Map toJson() => - { - "teacher_background": teacherBackground, - "teacher_avatar": teacherAvatar, - "room_name": roomName, - "start_time": startTime, - "teacher_name": teacherName, - "end_time": endTime, - "id": id, - }; + factory RoomInfoDto.fromJson(Map json) { + return RoomInfoDto( + studyRoomId: json["study_room_id"] ?? 0, + teacherId: json["teacher_id"] ?? 0, + teacherRtcUid: json["teacher_rtc_uid"] ?? 0, + teacherWsClientId: json["teacher_ws_client_id"] ?? "", + roomStatus: json["room_status"] ?? 0, + dataType: json["data_type"] ?? "", + roomStartTime: json["room_start_time"] ?? "", + roomEndTime: json["room_end_time"] ?? "", + boardUuid: json["whiteboard_uuid"] ?? "", + ); + } } diff --git a/lib/request/dto/room/room_list_item_dto.dart b/lib/request/dto/room/room_list_item_dto.dart new file mode 100644 index 0000000..06de174 --- /dev/null +++ b/lib/request/dto/room/room_list_item_dto.dart @@ -0,0 +1,51 @@ +class RoomListItemDto { + RoomListItemDto({ + required this.teacherGrade, + required this.roomName, + required this.startTime, + required this.teacherName, + required this.teacherAvatar, + required this.endTime, + required this.teacherSchoolName, + required this.teacherIntroduction, + required this.id, + required this.teacherMajor, + }); + + String teacherGrade; + String roomName; + String startTime; + String teacherName; + String teacherAvatar; + String endTime; + String teacherSchoolName; + String teacherIntroduction; + int id; + String teacherMajor; + + factory RoomListItemDto.fromJson(Map json) => RoomListItemDto( + teacherGrade: json["teacher_grade"], + roomName: json["room_name"], + startTime: json["start_time"], + teacherName: json["teacher_name"], + teacherAvatar: json["teacher_avatar"], + endTime: json["end_time"], + teacherSchoolName: json["teacher_school_name"], + teacherIntroduction: json["teacher_introduction"], + id: json["id"], + teacherMajor: json["teacher_major"], + ); + + Map toJson() => { + "teacher_grade": teacherGrade, + "room_name": roomName, + "start_time": startTime, + "teacher_name": teacherName, + "teacher_avatar": teacherAvatar, + "end_time": endTime, + "teacher_school_name": teacherSchoolName, + "teacher_introduction": teacherIntroduction, + "id": id, + "teacher_major": teacherMajor, + }; +} diff --git a/lib/request/dto/room/room_type_dto.dart b/lib/request/dto/room/room_type_dto.dart deleted file mode 100644 index e67f422..0000000 --- a/lib/request/dto/room/room_type_dto.dart +++ /dev/null @@ -1,39 +0,0 @@ -class RoomTypeDto { - final int studyRoomId; - final int teacherId; - final int teacherRtcUid; - final String teacherWsClientId; - final int roomStatus; - final String dataType; - - RoomTypeDto({ - required this.studyRoomId, - required this.teacherId, - required this.teacherRtcUid, - required this.teacherWsClientId, - required this.roomStatus, - required this.dataType, - }); - - Map toJson() { - final map = {}; - map["study_room_id"] = studyRoomId; - map["teacher_id"] = teacherId; - map["teacher_rtc_uid"] = teacherRtcUid; - map["teacher_ws_client_id"] = teacherWsClientId; - map["room_status"] = roomStatus; - map["data_type"] = dataType; - return map; - } - - factory RoomTypeDto.fromJson(Map json) { - return RoomTypeDto( - studyRoomId: json["study_room_id"] ?? 0, - teacherId: json["teacher_id"] ?? 0, - teacherRtcUid: json["teacher_rtc_uid"] ?? 0, - teacherWsClientId: json["teacher_ws_client_id"] ?? "", - roomStatus: json["room_status"] ?? 0, - dataType: json["data_type"] ?? "", - ); - } -} diff --git a/lib/request/dto/room/room_user_dto.dart b/lib/request/dto/room/room_user_dto.dart index dd08d36..353b46f 100644 --- a/lib/request/dto/room/room_user_dto.dart +++ b/lib/request/dto/room/room_user_dto.dart @@ -10,7 +10,7 @@ class RoomUserDto { /// 1是学生,2是老师 final int userType; - final List filesList; + List filesList; final String dataType; int handup; int online; //0离线,1在线 diff --git a/lib/request/websocket/room_protocol.dart b/lib/request/websocket/room_protocol.dart index 089c64f..370369f 100644 --- a/lib/request/websocket/room_protocol.dart +++ b/lib/request/websocket/room_protocol.dart @@ -80,6 +80,12 @@ enum RoomEvent { ///老师关闭学生的麦克风 closeStudentMic("sys_control_mute_microphone"), + ///老师打开学生的麦克风 + openStudentMic("sys_control_unmute_microphone"), + + ///老师开启学生的摄像头 + openStudentCamera("sys_control_unmute_camera"), + ///老师关闭学生的摄像头 closeStudentCamera("sys_control_mute_camera"), diff --git a/lib/request/websocket/room_websocket.dart b/lib/request/websocket/room_websocket.dart index 8a9b7df..c45f7b5 100644 --- a/lib/request/websocket/room_websocket.dart +++ b/lib/request/websocket/room_websocket.dart @@ -66,7 +66,10 @@ class RoomWebSocket { print("未识别的 action: ${jsonMap['action']},消息已忽略"); return; // 直接跳过 } else { - logger.i("接收到事件: ${event.value}"); + logger.i(""" + 接收到事件: ${event.value} + 数据: ${jsonMap['data']} + """); } final msg = RoomMessage(event, jsonMap['data']); _msgController.add(msg); @@ -96,7 +99,7 @@ class RoomWebSocket { "action": action.value, if (params != null) ...params, }; - if(action != RoomCommand.ping){ + if (action != RoomCommand.ping) { logger.i("发送指令:$msg"); } diff --git a/lib/utils/common.dart b/lib/utils/common.dart new file mode 100644 index 0000000..a93925d --- /dev/null +++ b/lib/utils/common.dart @@ -0,0 +1,6 @@ +import 'dart:io'; + +///判断是否是安卓 +bool isAndroid(){ + return Platform.isAndroid; +} \ No newline at end of file diff --git a/lib/utils/time.dart b/lib/utils/time.dart index e38c72b..dbdbc8b 100644 --- a/lib/utils/time.dart +++ b/lib/utils/time.dart @@ -33,21 +33,41 @@ String formatDate(dynamic date, [String format = 'YYYY-MM-DD hh:mm:ss']) { /// 将秒数格式化为 00:00 或 00:00:00 /// - [seconds]: 秒数 -String formatSeconds(int seconds) { - final h = seconds ~/ 3600; - final m = (seconds % 3600) ~/ 60; - final s = seconds % 60; +/// - [format]: 格式化字符串,默认为 "hh:mm:ss" +String formatSeconds( + int seconds, [ + String format = 'hh:mm:ss', +]) { + if (seconds < 0) seconds = 0; - String twoDigits(int n) => n.toString().padLeft(2, '0'); + int h = seconds ~/ 3600; + int m = (seconds % 3600) ~/ 60; + int s = seconds % 60; - if (h > 0) { - return '${twoDigits(h)}:${twoDigits(m)}:${twoDigits(s)}'; - } else { - return '${twoDigits(m)}:${twoDigits(s)}'; - } + String two(int n) => n.toString().padLeft(2, '0'); + + // 支持以下 token: + // hh = 补零小时, h = 不补零小时 + // mm = 补零分钟, m = 不补零分钟 + // ss = 补零秒, s = 不补零秒 + final replacements = { + 'hh': two(h), + 'mm': two(m), + 'ss': two(s), + 'h': h.toString(), + 'm': m.toString(), + 's': s.toString(), + }; + + String result = format; + + replacements.forEach((key, value) { + result = result.replaceAll(key, value); + }); + + return result; } - /// 将 "HH", "HH:mm" 或 "HH:mm:ss" 转为当天 DateTime DateTime parseTime(String timeStr) { final now = DateTime.now(); diff --git a/lib/utils/transfer/download.dart b/lib/utils/transfer/download.dart new file mode 100644 index 0000000..0a61389 --- /dev/null +++ b/lib/utils/transfer/download.dart @@ -0,0 +1,81 @@ +//下载文件 +import 'dart:io'; + +import 'package:path_provider/path_provider.dart'; + +class LocalDownload { + static Future getLocalFilePath(String url, String path) async { + Uri uri = Uri.parse(url); + String fileName = uri.pathSegments.last; + //获取下载目录 + Directory dir = await getApplicationCacheDirectory(); + Directory uploadPath = Directory("${dir.path}$path/"); + return uploadPath.path + fileName; + } + + /// 公用下载方法 + /// url 下载网络地址 + /// path 存储地址,如/test + /// onProgress 下载回调函数 + /// onDone 下载完毕回调 + static downLoadFile({ + required url, + required path, + required Function(double) onProgress, + required Function(String) onDone, + }) async { + HttpClient client = HttpClient(); + Uri uri = Uri.parse(url); + //获取本地文件路径 + String filePath = await getLocalFilePath(url, path); + // 发起 get 请求 + HttpClientRequest request = await client.getUrl(uri); + // 响应 + HttpClientResponse response = await request.close(); + int contentLength = response.contentLength; // 获取文件总大小 + int bytesReceived = 0; // 已接收的字节数 + List chunkList = []; + if (response.statusCode == 200) { + response.listen( + (List chunk) { + chunkList.addAll(chunk); + bytesReceived += chunk.length; //更新已接受的字节数 + //进度 + double progress = bytesReceived * 100 / contentLength * 100; + progress = (progress / 100).truncateToDouble(); + onProgress(progress); + }, + onDone: () async { + //下载完毕 + client.close(); + File file = File(filePath); + if (!file.existsSync()) { + file.createSync(recursive: true); + await file.writeAsBytes(chunkList); + } + onDone(file.path); + }, + onError: () { + client.close(); + }, + cancelOnError: true, + ); + } + } + + ///获取本地地址 + /// + static Future getFilePath({ + required url, + required path, + }) async { + //获取本地文件路径 + String filePath = await getLocalFilePath(url, path); + File file = File(filePath); + if (file.existsSync()) { + return file.path; + } else { + return ''; + } + } +} diff --git a/lib/utils/transfer/upload.dart b/lib/utils/transfer/upload.dart new file mode 100644 index 0000000..8eec37c --- /dev/null +++ b/lib/utils/transfer/upload.dart @@ -0,0 +1,55 @@ +import 'dart:io'; + +import 'package:app/config/config.dart'; +import 'package:app/request/api/common_api.dart'; +import 'package:app/request/dto/common/qiu_token_dto.dart'; +import 'package:crypto/crypto.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; + +class QinUpload { + ///获取七牛token + static Future _getQiuToken(File file, String path) async { + // 读取文件的字节数据 + final fileBytes = await file.readAsBytes(); + String fileMd5 = md5.convert(fileBytes).toString(); + //前缀 + var prefix = Config.getEnv() == "dev" ? "test" : "release"; + var suffix = file.path.split(".").last; + + var res = await getQiuTokenApi( + "xueguang/$prefix/$path/$fileMd5.$suffix", + ); + return res; + } + + ///上传文件 + /// - [file] 文件 + /// - [path] 目标目录 + static Future upload({ + required File file, + required String path, + }) async { + var qiuToken = await _getQiuToken(file, path); + //数据 + FormData formData = FormData.fromMap({ + "file": await MultipartFile.fromFile(file.path), + "token": qiuToken.upToken, + "fname": qiuToken.fileKey, + "key": qiuToken.fileKey, + }); + try { + Dio dio = Dio(); + Response response = await dio.post( + qiuToken.uploadUrl!, + data: formData, + onSendProgress: (int sent, int total) {}, + ); + String key = response.data['key']; + return "https://${qiuToken.domain}/$key"; + } catch (e) { + EasyLoading.showError("上传失败"); + return null; + } + } +} diff --git a/lib/widgets/base/actionSheet/action_sheet.dart b/lib/widgets/base/actionSheet/action_sheet.dart new file mode 100644 index 0000000..b2cd915 --- /dev/null +++ b/lib/widgets/base/actionSheet/action_sheet.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +import 'action_sheet_ui.dart'; +import 'type.dart'; + +void showActionSheet( + BuildContext context, { + required List actions, + bool showCancel = false, + required Function(ActionSheetItem) onConfirm, +}) { + showModalBottomSheet( + context: context, + backgroundColor: Colors.white, + clipBehavior: Clip.antiAlias, + builder: (context) { + return ActionSheetUi( + actions: actions, + showCancel: showCancel, + onConfirm: onConfirm, + ); + }, + ); +} + + diff --git a/lib/widgets/base/actionSheet/action_sheet_ui.dart b/lib/widgets/base/actionSheet/action_sheet_ui.dart new file mode 100644 index 0000000..a82343a --- /dev/null +++ b/lib/widgets/base/actionSheet/action_sheet_ui.dart @@ -0,0 +1,65 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import 'type.dart'; + +class ActionSheetUi extends StatelessWidget { + final List actions; + final bool showCancel; + final double actionHeight = 50; + final Function(ActionSheetItem) onConfirm; + + const ActionSheetUi({ + super.key, + required this.actions, + this.showCancel = false, + required this.onConfirm, + }); + + @override + Widget build(BuildContext context) { + return SizedBox( + width: double.infinity, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ...actions.map((item) { + return InkWell( + onTap: () { + onConfirm(item); + context.pop(); + }, + child: Container( + height: actionHeight, + alignment: Alignment.center, + child: Text(item.title), + ), + ); + }), + Visibility( + visible: showCancel, + child: Column( + children: [ + Container( + width: double.infinity, + height: 8, + color: const Color.fromRGBO(238, 239, 243, 1.0), + ), + InkWell( + onTap: () { + context.pop(); + }, + child: Container( + height: actionHeight, + alignment: Alignment.center, + child: const Text("取消"), + ), + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/base/actionSheet/type.dart b/lib/widgets/base/actionSheet/type.dart new file mode 100644 index 0000000..4d55b7f --- /dev/null +++ b/lib/widgets/base/actionSheet/type.dart @@ -0,0 +1,18 @@ +import 'package:flutter/material.dart'; + +/// 底部弹窗类型 +class ActionSheetItem { + String title; //标题 + int value; + bool disabled; //是否禁用 + Color color; //选项颜色 + Widget? child; + + ActionSheetItem({ + required this.title, + this.value = 0, + this.disabled = false, + this.color = Colors.black, + this.child, + }); +} diff --git a/lib/widgets/base/button/index.dart b/lib/widgets/base/button/index.dart index 3c61852..c2ad5b1 100644 --- a/lib/widgets/base/button/index.dart +++ b/lib/widgets/base/button/index.dart @@ -6,18 +6,20 @@ import '../config/config.dart'; class Button extends StatelessWidget { final double? width; final String text; + final TextStyle textStyle; final ThemeType type; final BorderRadius radius; - final VoidCallback onPressed; + final VoidCallback? onPressed; final bool loading; final bool disabled; const Button({ super.key, this.width, + this.textStyle = const TextStyle(), this.radius = const BorderRadius.all(Radius.circular(80)), required this.text, - required this.onPressed, + this.onPressed, this.type = ThemeType.primary, this.loading = false, this.disabled = false, @@ -34,7 +36,7 @@ class Button extends StatelessWidget { }; return Opacity( - opacity: disabled || loading ? 0.5 : 1, + opacity: disabled || loading ? 0.5 : 1, child: Container( width: width, decoration: bgDecoration.copyWith(borderRadius: radius), @@ -62,7 +64,7 @@ class Button extends StatelessWidget { ), Text( text, - style: TextStyle( + style: textStyle.copyWith( color: type != ThemeType.info ? Colors.white : Colors.black, ), textAlign: TextAlign.center, diff --git a/lib/widgets/common/preview/file_previewer.dart b/lib/widgets/common/preview/file_previewer.dart index cf7e243..6c739a2 100644 --- a/lib/widgets/common/preview/file_previewer.dart +++ b/lib/widgets/common/preview/file_previewer.dart @@ -40,6 +40,9 @@ class FilePreviewer extends StatelessWidget { child = InteractiveViewer( child: CachedNetworkImage( imageUrl: url, + placeholder: (_, __) => const Center( + child: CircularProgressIndicator(), + ), ), ); } else if (_isPdf(suffix)) { diff --git a/lib/widgets/room/core/count_down_vm.dart b/lib/widgets/room/core/count_down_vm.dart new file mode 100644 index 0000000..209d5a2 --- /dev/null +++ b/lib/widgets/room/core/count_down_vm.dart @@ -0,0 +1,93 @@ +import 'dart:async'; + +import 'package:app/data/models/meeting_room_dto.dart'; +import 'package:app/utils/time.dart'; +import 'package:flutter/cupertino.dart'; + +class CountDownVM extends ChangeNotifier { + MeetingRoomDto? roomInfo; + + ///会议开始倒计时秒数 + Timer? _startTime; + int _startCountDown = 0; + + ///会议结束倒计时秒数 + Timer? _endTime; + int _endCountDown = -1; + + ///会议进行中的秒数 + int studyTime = 0; + + int get startCountDown => _startCountDown; + + int get endCountDown => _endCountDown; + + ///是否能开始自习室 + bool get canEnterRoom { + final now = DateTime.now(); + if (now.isAfter(parseTime(roomInfo!.startTime))) { + return true; + } + return false; + } + + //绑定 + void bind(MeetingRoomDto info) { + if (roomInfo == info) return; + roomInfo = info; + _startEndCountdown(); + //如果会议室结束,停止计时器 + if (roomInfo?.roomStatus == 2) { + _endTime?.cancel(); + } + } + + ///启动距离会议结束还有多少秒 + void _startEndCountdown() { + if (roomInfo!.actualStartTime.isEmpty || roomInfo!.roomStatus != 1) return; + _endTime?.cancel(); + + DateTime endTime = parseTime(roomInfo!.endTime); + DateTime startTime = DateTime.parse(roomInfo!.actualStartTime); + + _endCountDown = endTime.difference(startTime).inSeconds; + + _endTime = Timer.periodic(Duration(seconds: 1), (timer) { + _endCountDown--; + studyTime++; + if (_endCountDown <= 0) { + _endTime?.cancel(); + } + notifyListeners(); + }); + } + + ///启动距离会议开始还有多少秒 + void startStartCountdown() { + if (roomInfo?.roomStatus != 0) return; + _startTime?.cancel(); + final now = DateTime.now(); + final startTime = parseTime(roomInfo!.startTime); + + _startCountDown = startTime.difference(now).inSeconds; + if (_startCountDown <= 0) { + return; + } + + _startTime = Timer.periodic(Duration(seconds: 1), (timer) { + _startCountDown--; + if (_startCountDown <= 0) { + _startTime?.cancel(); + _startTime = null; + } + notifyListeners(); + }); + } + + @override + void dispose() { + _startTime?.cancel(); + _endTime?.cancel(); + super.dispose(); + } +} diff --git a/lib/widgets/room/file_drawer.dart b/lib/widgets/room/file_drawer.dart index ace88f1..5113ead 100644 --- a/lib/widgets/room/file_drawer.dart +++ b/lib/widgets/room/file_drawer.dart @@ -1,6 +1,14 @@ +import 'dart:io'; + import 'package:app/config/theme/base/app_theme_ext.dart'; +import 'package:app/utils/transfer/upload.dart'; +import 'package:app/widgets/base/actionSheet/action_sheet.dart'; +import 'package:app/widgets/base/actionSheet/type.dart'; import 'package:app/widgets/base/button/index.dart'; +import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; +import 'package:flutter_easyloading/flutter_easyloading.dart'; +import 'package:image_picker/image_picker.dart'; import '../common/preview/file_previewer.dart'; @@ -8,6 +16,9 @@ import '../common/preview/file_previewer.dart'; void showFileDialog( BuildContext context, { bool isUpload = true, + String? name, + List files = const [], + ValueChanged>? onConfirm, }) { showGeneralDialog( context: context, @@ -16,7 +27,10 @@ void showFileDialog( barrierLabel: "RightSheet", pageBuilder: (context, animation, secondaryAnimation) { return FileDrawer( + name: name, isUpload: isUpload, + files: files, + onConfirm: onConfirm, ); }, transitionBuilder: (context, animation, secondaryAnimation, child) { @@ -31,15 +45,99 @@ void showFileDialog( ///文件弹窗 class FileDrawer extends StatefulWidget { + final String? name; + final List files; final bool isUpload; + final ValueChanged>? onConfirm; - const FileDrawer({super.key, this.isUpload = true}); + const FileDrawer({ + super.key, + this.name, + this.isUpload = true, + this.files = const [], + this.onConfirm, + }); @override State createState() => _FileDrawerState(); } class _FileDrawerState extends State { + ///文件列表 + List _fileList = []; + + @override + void initState() { + super.initState(); + _fileList = List.from(widget.files); + } + + ///打开选择面板 + void _handOpenActionSheet() { + showActionSheet( + context, + showCancel: true, + actions: [ + ActionSheetItem(title: "拍照", value: 1), + ActionSheetItem(title: "选择图片", value: 2), + ActionSheetItem(title: "选择PDF", value: 3), + ], + onConfirm: (res) async { + List filesToUpload = []; + try { + if (res.value == 1) { + final ImagePicker picker = ImagePicker(); + final XFile? photo = await picker.pickImage(source: ImageSource.camera); + + if (photo == null) return; // 用户取消 + filesToUpload.add(File(photo.path)); + } else if (res.value == 2) { + //选择图片 + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.image, + ); + if (result == null) return; + filesToUpload.addAll(result.paths.whereType().map((e) => File(e))); + } else if (res.value == 3) { + //选择pdf + final result = await FilePicker.platform.pickFiles( + allowMultiple: true, + type: FileType.custom, + allowedExtensions: ['pdf'], + ); + if (result == null) return; + filesToUpload.addAll(result.paths.whereType().map((e) => File(e))); + } + if (filesToUpload.isEmpty) return; + // 4) 上传文件 + EasyLoading.show(status: "文件上传中"); + + final uploadTasks = filesToUpload.map((file) { + return QinUpload.upload( + file: file, + path: "room/classroom", + ); + }); + final List uploadedPaths = (await Future.wait( + uploadTasks, + )).whereType().toList(); + + EasyLoading.dismiss(); + + // 更新 UI + setState(() => _fileList.addAll(uploadedPaths)); + + // 回调 + widget.onConfirm?.call(uploadedPaths); + } catch (e) { + print(e); + EasyLoading.showToast("文件上传失败"); + } + }, + ); + } + @override Widget build(BuildContext context) { return Align( @@ -52,39 +150,45 @@ class _FileDrawerState extends State { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - '上传文件列表', + "${widget.name ?? ""}上传文件列表", style: Theme.of(context).textTheme.titleSmall, ), Expanded( - child: ListView.separated( - padding: EdgeInsets.symmetric(vertical: 15), - itemBuilder: (_, index) { - return InkWell( - onTap: () { - showFilePreviewer( - context, - url: "https://doaf.asia/api/assets/1/图/65252305_p0.jpg", - ); - }, - child: Container( - padding: EdgeInsets.symmetric(vertical: 10, horizontal: 10), - decoration: BoxDecoration( - color: Theme.of(context).colorScheme.surfaceContainer, - borderRadius: BorderRadius.circular(5), + child: Visibility( + visible: _fileList.isNotEmpty, + replacement: Align( + child: Text("未上传文件"), + ), + child: ListView.separated( + padding: EdgeInsets.symmetric(vertical: 15), + itemBuilder: (_, index) { + String item = _fileList[index]; + String suffix = item.split(".").last; + return InkWell( + key: Key(item), + onTap: () { + showFilePreviewer(context, url: item); + }, + child: Container( + padding: EdgeInsets.symmetric(vertical: 10, horizontal: 10), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surfaceContainer, + borderRadius: BorderRadius.circular(5), + ), + child: Text("文件${index + 1}.$suffix", style: TextStyle(fontSize: 14)), ), - child: Text("文件1.png", style: TextStyle(fontSize: 14)), - ), - ); - }, - separatorBuilder: (_, __) => SizedBox(height: 15), - itemCount: 15, + ); + }, + separatorBuilder: (_, __) => SizedBox(height: 15), + itemCount: _fileList.length, + ), ), ), Visibility( visible: widget.isUpload, child: Button( text: "上传", - onPressed: () {}, + onPressed: _handOpenActionSheet, ), ), ], @@ -93,3 +197,12 @@ class _FileDrawerState extends State { ); } } + +///数据类 +class UploadFileItem { + String url; + String name; + bool loading; + + UploadFileItem({required this.url, this.loading = false, required this.name}); +} diff --git a/lib/widgets/room/other_widget.dart b/lib/widgets/room/other_widget.dart new file mode 100644 index 0000000..fa3e854 --- /dev/null +++ b/lib/widgets/room/other_widget.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; + +class HandRaiseButton extends StatelessWidget { + final void Function() onTap; + const HandRaiseButton({super.key, required this.onTap}); + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + child: Container( + height: 60, + width: 60, + decoration: BoxDecoration( + color: Colors.black12, + shape: BoxShape.circle, + ), + child: Icon( + Icons.back_hand_rounded, + color: Color(0xFFFDC400), + size: 24, + ), + ), + ); + } +} diff --git a/lib/widgets/room/video_surface.dart b/lib/widgets/room/video_surface.dart index 2900951..9bbcac0 100644 --- a/lib/widgets/room/video_surface.dart +++ b/lib/widgets/room/video_surface.dart @@ -1,46 +1,52 @@ -import 'package:app/config/config.dart'; +import 'package:app/request/dto/room/room_user_dto.dart'; +import 'package:cached_network_image/cached_network_image.dart'; import 'package:flutter/material.dart'; -import 'package:agora_rtc_engine/agora_rtc_engine.dart'; -import '../../request/dto/room/rtc_token_dto.dart'; - -/// 视频画面显示状态 -enum VideoState { - /// 正常显示视频 - normal, - - /// 摄像头关闭 - closed, - - /// 掉线 / 未连接 - offline, - - /// 加载中(进房、拉流等) - loading, - - /// 错误状态(拉流失败等) - error, -} class VideoSurface extends StatelessWidget { - final VideoState state; + final RoomUserDto user; + final Widget child; - const VideoSurface({super.key, this.state = VideoState.normal}); + const VideoSurface({ + super.key, + required this.user, + required this.child, + }); @override Widget build(BuildContext context) { - String stateText = switch (state) { - VideoState.closed => "摄像头已关闭", - VideoState.offline => "掉线", - VideoState.loading => "加载中", - VideoState.error => "错误", - _ => "未知", - }; - //如果不是正常 - if (state != VideoState.normal) { + //摄像头是否关闭 + if (user.cameraStatus == 0) { return Align( - child: Text(stateText, style: TextStyle(color: Colors.white70)), + child: ConstrainedBox( + constraints: const BoxConstraints( + maxWidth: 70, + ), + child: AspectRatio( + aspectRatio: 1, + child: Container( + clipBehavior: Clip.hardEdge, + decoration: BoxDecoration( + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 1), + ), + child: CachedNetworkImage( + imageUrl: user.avatar, + fit: BoxFit.cover, + ), + ), + ), + ), ); } - return Container(); + if (user.online == 0) { + return _empty('暂时离开'); + } + return child; + } + + Widget _empty(String title) { + return Center( + child: Text(title, style: TextStyle(color: Colors.white70)), + ); } } diff --git a/lib/widgets/version/version_dialog.dart b/lib/widgets/version/version_dialog.dart new file mode 100644 index 0000000..7046de9 --- /dev/null +++ b/lib/widgets/version/version_dialog.dart @@ -0,0 +1,80 @@ + +import 'dart:convert'; + +import 'package:app/request/api/common_api.dart'; +import 'package:app/utils/common.dart'; +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:package_info_plus/package_info_plus.dart'; + +import 'version_ui.dart'; + + +///显示版本更新弹窗 +void showUpdateDialog(BuildContext context) async { + PackageInfo packageInfo = await PackageInfo.fromPlatform(); + //如果是安卓 + if (isAndroid()) { + //版本信息 + var versionRes = await getAppVersionApi(); + + //比较版本 + int compareResult = compareVersions( + packageInfo.version, + versionRes.latestVersion, + ); + if (compareResult > -1) { + return; + } + + //如果是最新版本 + showGeneralDialog( + context: context, + barrierDismissible: false, + barrierLabel: "Update", + transitionDuration: const Duration(milliseconds: 300), + pageBuilder: (context, animation, secondaryAnimation) { + return PopScope( + canPop: false, + child: AppUpdateUi( + version: versionRes.latestVersion, + updateNotice: versionRes.updateContent, + uploadUrl: versionRes.downloadUrl, + ), + ); + }, + ); + } else { + var res = await Dio().get("https://itunes.apple.com/lookup?bundleId=com.curainhealth.akhome"); + Map resJson = json.decode(res.data); + if (resJson['results'].length == 0) { + return; + } + var newVersion = resJson['results'][0]['version']; + //比较版本 + int compareResult = compareVersions( + packageInfo.version, + newVersion, + ); + if (compareResult > 0) { + return; + } + + } +} + +///比较版本号 +int compareVersions(String version1, String version2) { + List v1Parts = version1.split('.'); + List v2Parts = version2.split('.'); + int length = v1Parts.length > v2Parts.length ? v1Parts.length : v2Parts.length; + + for (int i = 0; i < length; i++) { + int v1Part = i < v1Parts.length ? int.tryParse(v1Parts[i]) ?? 0 : 0; + int v2Part = i < v2Parts.length ? int.tryParse(v2Parts[i]) ?? 0 : 0; + + if (v1Part > v2Part) return 1; + if (v1Part < v2Part) return -1; + } + return 0; +} diff --git a/lib/widgets/version/version_ui.dart b/lib/widgets/version/version_ui.dart new file mode 100644 index 0000000..bad0bfb --- /dev/null +++ b/lib/widgets/version/version_ui.dart @@ -0,0 +1,174 @@ +import 'package:app/widgets/base/button/index.dart'; +import 'package:app/widgets/base/tag/index.dart'; +import 'package:app_installer/app_installer.dart'; +import 'package:flutter/material.dart'; + +import '../../utils/transfer/download.dart'; +import '../base/config/config.dart'; + +///下载状态枚举 +enum UploadState { + notStarted, //未开始下载 + downloading, //下载中 + completed, //下载完毕 +} + +class AppUpdateUi extends StatefulWidget { + final String version; + final List updateNotice; + final String uploadUrl; //下载地址 + + const AppUpdateUi({ + super.key, + required this.version, + required this.updateNotice, + required this.uploadUrl, + }); + + @override + State createState() => _UpdateUiState(); +} + +class _UpdateUiState extends State { + int _uploadProgress = 0; //下载进度 + UploadState _uploadState = UploadState.notStarted; + + @override + void initState() { + super.initState(); + getLocalApk(); + } + + ///读取本地是否有下载记录 + void getLocalApk() async { + String url = await LocalDownload.getFilePath(url: widget.uploadUrl, path: '/apk'); + if (url.isNotEmpty) { + setState(() { + _uploadState = UploadState.completed; + }); + } + } + + ///下载apk + void _handUploadApk() async { + if (_uploadState == UploadState.notStarted) { + setState(() { + _uploadState = UploadState.downloading; + }); + LocalDownload.downLoadFile( + url: widget.uploadUrl, + path: "/apk", + onProgress: (double double) { + setState(() { + _uploadProgress = double.toInt(); + }); + }, + onDone: (apk) async { + setState(() { + _uploadState = UploadState.completed; + }); + AppInstaller.installApk(apk); + }, + ); + } else if (_uploadState == UploadState.completed) { + String url = await LocalDownload.getFilePath(url: widget.uploadUrl, path: '/apk'); + AppInstaller.installApk(url); + } + } + + @override + Widget build(BuildContext context) { + String text; + if (_uploadState == UploadState.downloading) { + text = "$_uploadProgress%"; + } else if (_uploadState == UploadState.completed) { + text = '安装'; + } else { + text = '立即升级'; + } + return IntrinsicHeight( + child: Container( + color: Colors.transparent, + padding: EdgeInsets.symmetric(horizontal: 40), + alignment: Alignment.center, + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: 500, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Stack( + fit: StackFit.passthrough, + children: [ + Image.asset("assets/image/version_bg.png"), + Positioned( + top: 0, + left: 0, + right: 0, + bottom: 0, + child: DefaultTextStyle( + style: TextStyle( + fontSize: 16, + color: Colors.white, + fontWeight: FontWeight.w700, + ), + child: FractionalTranslation( + translation: Offset(0.35, 0.3), + child: Row( + mainAxisAlignment: MainAxisAlignment.start, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text("发现新版本"), + SizedBox(width: 10), + Tag( + text: "V ${widget.version}", + type: ThemeType.warning, + ), + ], + ), + ), + ), + ), + ], + ), + Material( + borderRadius: BorderRadius.only( + bottomLeft: Radius.circular(20), + bottomRight: Radius.circular(20), + ), + child: Container( + padding: EdgeInsets.all(15), + width: double.infinity, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ...widget.updateNotice.asMap().entries.map((entry) { + final index = entry.key; + final item = entry.value; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 5), + child: Text("${index + 1}.$item"), + ); + }), + Container( + margin: EdgeInsets.only(top: 20), + height: 40, + child: Button( + text: text, + onPressed: _uploadState == UploadState.downloading + ? null + : _handUploadApk, + ), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/pubspec.lock b/pubspec.lock index 382f1bd..64ea3b6 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -9,6 +9,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "6.5.3" + app_installer: + dependency: "direct main" + description: + name: app_installer + sha256: "4c3a9268b53ead9a915ef79cd3988e28c72719fb78143867f9fed4bd4d8c1cfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + args: + dependency: transitive + description: + name: args + sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" async: dependency: transitive description: @@ -73,8 +89,16 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" - crypto: + cross_file: dependency: transitive + description: + name: cross_file + sha256: "701dcfc06da0882883a2657c445103380e53e647060ad8d9dfb710c100996608" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.5+1" + crypto: + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -89,6 +113,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.8" + dbus: + dependency: transitive + description: + name: dbus + sha256: "79e0c23480ff85dc68de79e2cd6334add97e48f7f4865d17686dd6ea81a47e8c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.7.11" dio: dependency: "direct main" description: @@ -129,6 +161,46 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" + file_picker: + dependency: "direct main" + description: + name: file_picker + sha256: "7872545770c277236fd32b022767576c562ba28366204ff1a5628853cf8f2200" + url: "https://pub.flutter-io.cn" + source: hosted + version: "10.3.7" + file_selector_linux: + dependency: transitive + description: + name: file_selector_linux + sha256: "2567f398e06ac72dcf2e98a0c95df2a9edd03c2c2e0cacd4780f20cdf56263a0" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4" + file_selector_macos: + dependency: transitive + description: + name: file_selector_macos + sha256: "19124ff4a3d8864fdc62072b6a2ef6c222d55a3404fe14893a3c02744907b60c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.4+4" + file_selector_platform_interface: + dependency: transitive + description: + name: file_selector_platform_interface + sha256: "35e0bd61ebcdb91a3505813b055b09b79dfdc7d0aee9c09a7ba59ae4bb13dc85" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.7.0" + file_selector_windows: + dependency: transitive + description: + name: file_selector_windows + sha256: "62197474ae75893a62df75939c777763d39c2bc5f73ce5b88497208bc269abfd" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.9.3+5" fixnum: dependency: transitive description: @@ -187,6 +259,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.4.3" + flutter_plugin_android_lifecycle: + dependency: transitive + description: + name: flutter_plugin_android_lifecycle + sha256: c2fe1001710127dfa7da89977a08d591398370d099aacdaa6d44da7eb14b8476 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.31" flutter_screenutil: dependency: "direct main" description: @@ -237,6 +317,70 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" + image_picker: + dependency: "direct main" + description: + name: image_picker + sha256: "784210112be18ea55f69d7076e2c656a4e24949fa9e76429fe53af0c0f4fa320" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + image_picker_android: + dependency: transitive + description: + name: image_picker_android + sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13+1" + image_picker_for_web: + dependency: transitive + description: + name: image_picker_for_web + sha256: "66257a3191ab360d23a55c8241c91a6e329d31e94efa7be9cf7a212e65850214" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.1" + image_picker_ios: + dependency: transitive + description: + name: image_picker_ios + sha256: eb06fe30bab4c4497bad449b66448f50edcc695f1c59408e78aa3a8059eb8f0e + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.8.13" + image_picker_linux: + dependency: transitive + description: + name: image_picker_linux + sha256: "1f81c5f2046b9ab724f85523e4af65be1d47b038160a8c8deed909762c308ed4" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_macos: + dependency: transitive + description: + name: image_picker_macos + sha256: d58cd9d67793d52beefd6585b12050af0a7663c0c2a6ece0fb110a35d6955e04 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" + image_picker_platform_interface: + dependency: transitive + description: + name: image_picker_platform_interface + sha256: "567e056716333a1647c64bb6bd873cff7622233a5c3f694be28a583d4715690c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.1" + image_picker_windows: + dependency: transitive + description: + name: image_picker_windows + sha256: d248c86554a72b5495a31c56f060cf73a41c7ff541689327b1a7dbccc33adfae + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.2.2" intl: dependency: "direct main" description: @@ -485,6 +629,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "0.2.1" + petitparser: + dependency: transitive + description: + name: petitparser + sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "7.0.1" platform: dependency: transitive description: @@ -722,6 +874,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "15.0.0" + wakelock_plus: + dependency: "direct main" + description: + name: wakelock_plus + sha256: "61713aa82b7f85c21c9f4cd0a148abd75f38a74ec645fcb1e446f882c82fd09b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.3" + wakelock_plus_platform_interface: + dependency: transitive + description: + name: wakelock_plus_platform_interface + sha256: "036deb14cd62f558ca3b73006d52ce049fabcdcb2eddfe0bf0fe4e8a943b5cf2" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" web: dependency: transitive description: @@ -746,6 +914,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.1.0" + xml: + dependency: transitive + description: + name: xml + sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.6.1" sdks: dart: ">=3.8.1 <4.0.0" - flutter: ">=3.31.0-0.0.pre" + flutter: ">=3.32.0" diff --git a/pubspec.yaml b/pubspec.yaml index 1aa9f51..1987257 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -2,7 +2,7 @@ name: app description: "A new Flutter project." publish_to: 'none' # Remove this line if you wish to publish to pub.dev -version: 1.0.0+1 +version: 1.1.1 environment: sdk: ^3.8.1 @@ -28,6 +28,11 @@ dependencies: flutter_cached_pdfview: ^0.4.3 skeletonizer: ^2.1.0+1 agora_rtc_engine: ^6.5.3 + crypto: ^3.0.0 + file_picker: ^10.3.7 + app_installer: ^1.3.1 + wakelock_plus: ^1.3.3 + image_picker: ^1.2.0 dev_dependencies: flutter_test: