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 0000000..2d8c7e2 Binary files /dev/null and b/assets/image/version_bg.png differ 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 0000000..2e7f4fa Binary files /dev/null and b/key.jks differ 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: