自习室优化ok

This commit is contained in:
zhutao
2025-11-28 13:31:23 +08:00
parent 4ecb0c35d6
commit 57305c5804
57 changed files with 2500 additions and 597 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -47,6 +47,18 @@
<meta-data
android:name="flutterEmbedding"
android:value="2" />
<!-- 用于更新检查-->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileProvider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<!-- 允许访问缓存目录下的apk目录 -->
<cache-path
name="apk_cache"
path="apk/" />
</paths>

BIN
assets/image/version_bg.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

1
build.dev.sh Normal file
View File

@@ -0,0 +1 @@
flutter build apk -Penv=dev

26
build.prod.sh Normal file
View File

@@ -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."

BIN
key.jks Normal file

Binary file not shown.

View File

@@ -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<String, dynamic> 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<String, dynamic> 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,
);
}
}

View File

@@ -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()
],
),
),

View File

@@ -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<STodayCard> {
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<STodayCard> {
),
InfoItem(
label: "自习时长",
value: "${vm.roomMinutes} 分钟",
value: "${formatSeconds(vm.roomMinutes * 60, 'hh小时mm分钟')} ",
icon: RemixIcons.timer_line,
color: context.success,
),

View File

@@ -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() {

View File

@@ -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<FeatureItem> 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);
}

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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<BottomBar> createState() => _BottomBarState();
@@ -15,51 +16,77 @@ class BottomBar extends StatefulWidget {
class _BottomBarState extends State<BottomBar> {
///显示文件
void _handShowFile() {
showFileDialog(context);
final vm = context.read<StuRoomVM>();
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<StuRoomVM>();
if (vm.roomInfo.roomStatus != 1) {
return SizedBox();
}
return Container(
decoration: BoxDecoration(
color: Color(0xff232426),
),
height: 70,
child: Consumer<StuRoomVM>(
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,
),
],
);
}
},
),
);
}

View File

@@ -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<TopBar> createState() => _TopBarState();
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
class _TopBarState extends State<TopBar> {
Timer? _timer;
int seconds = 0;
late DateTime startTime;
@override
void initState() {
super.initState();
final vm = context.read<StuRoomVM>();
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<StuRoomVM>();
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<CountDownVM>(
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);
}

View File

@@ -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<SRoomPage> {
@override
Widget build(BuildContext context) {
UserStore userStore = context.read<UserStore>();
return ChangeNotifierProvider<StuRoomVM>(
create: (_) => StuRoomVM(
roomInfo: widget.roomInfo,
uid: userStore.userInfo!.id,
),
return MultiProvider(
providers: [
ChangeNotifierProvider<StuRoomVM>(
create: (_) => StuRoomVM(
info: widget.roomInfo,
uid: userStore.userInfo!.id,
),
),
ChangeNotifierProxyProvider<StuRoomVM, CountDownVM>(
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<SRoomPage> {
child: SlideHide(
direction: SlideDirection.down,
hide: !_controlsVisible,
child: BottomBar(),
child: BottomBar(
onTap: _toggleOverlay,
),
),
),
],

View File

@@ -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<StuRoomVM>();
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(

View File

@@ -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<TeacherVideo> createState() => _TeacherVideoState();
}
class _TeacherVideoState extends State<TeacherVideo> {
@override
Widget build(BuildContext context) {
final vm = context.read<StuRoomVM>();
final vm = context.watch<StuRoomVM>();
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,
),
),
],
),
),
);
}
}
}

View File

@@ -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<void> _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<String> 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();
}
}

View File

@@ -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<StuRoomVM>();
final teacherInfo = vm.teacherInfo;
///没开始
if (vm.roomInfo.roomStatus == 0) {
return Consumer<CountDownVM>(
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),
),
),
);
}
}

View File

@@ -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<HomeViewModel>();
return Scaffold(
backgroundColor: Theme.of(context).colorScheme.surfaceContainer,
appBar: Header(),
@@ -36,6 +40,8 @@ class _HomeView extends StatelessWidget {
),
children: [
TodayCard(),
TipCard1(),
TipCard2(),
],
),
),

View File

@@ -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() {

View File

@@ -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,
),
],
),
);
}
}

View File

@@ -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<TodayCard> {
),
_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<TodayCard> {
child: Button(
text: vm.canEnterRoom ? "开始自习室" : "未到开始时间",
type: ThemeType.success,
// disabled: !vm.canEnterRoom,
disabled: !vm.canEnterRoom,
onPressed: _goToRoom,
),
),

View File

@@ -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<TchRoomVM>();
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<CountDownVM>(
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<TchRoomVM>(
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<TchRoomVM>();
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);
}

View File

@@ -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<TRoomPage> {
@override
Widget build(BuildContext context) {
return ChangeNotifierProvider<TchRoomVM>(
create: (BuildContext context) {
return TchRoomVM(
roomInfo: widget.roomInfo,
);
},
return MultiProvider(
providers: [
ChangeNotifierProvider<TchRoomVM>(
create: (_) => TchRoomVM(info: widget.roomInfo),
),
ChangeNotifierProxyProvider<TchRoomVM, CountDownVM>(
create: (_) => CountDownVM(),
update: (_, tchVM, countDownVM) {
countDownVM!.bind(tchVM.roomInfo);
return countDownVM;
},
),
],
child: Scaffold(
backgroundColor: Color(0xff2c3032),
appBar: TopBar(),

View File

@@ -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<RoomUserDto> _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<RoomMessage>? _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<String, dynamic> data = {
'target_user_id': "all",
"mute_type": action.value,
"is_mute": 1,
};
_ws.send(RoomCommand.switchStudentCamera, data);
}
//清除全部学生举手,或者是指定
void clearHandUp(int? id) {
Map<String, dynamic> 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<String> 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() {

View File

@@ -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<ContentView> {
@override
void dispose() {
super.dispose();
WakelockPlus.disable();
_dispose();
}
void _initRtc() async {
UserStore userStore = context.read<UserStore>();
final vm = context.read<TchRoomVM>();
_engine = createAgoraRtcEngine();
//初始化 RtcEngine设置频道场景为 channelProfileLiveBroadcasting直播场景
@@ -41,39 +41,33 @@ class _ContentViewState extends State<ContentView> {
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<ContentView> {
return Consumer<TchRoomVM>(
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<ContentView> {
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(

View File

@@ -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<StatusView> {
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<CountDownVM>();
countVM.removeListener(_onCountDownEnd);
countVM.addListener(_onCountDownEnd);
}
///开播中返回拦截弹窗
@@ -72,55 +44,65 @@ class _StatusViewState extends State<StatusView> {
);
}
///监听会议室倒计时结束的时候
void _onCountDownEnd() {
final countVM = context.read<CountDownVM>();
if (countVM.endCountDown == 0) {
EasyLoading.showToast("自习室已到结束时间,请记得关闭会议室");
countVM.removeListener(_onCountDownEnd);
}
}
@override
Widget build(BuildContext context) {
final vm = context.watch<TchRoomVM>();
final tchVM = context.watch<TchRoomVM>();
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<CountDownVM>(
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, _) {

View File

@@ -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<StudentItem> {
///打开文件列表
void _openFileList() {
showFileDialog(context, isUpload: false);
showFileDialog(
context,
isUpload: false,
files: widget.user.filesList,
);
}
@override
@@ -40,7 +45,6 @@ class _StudentItemState extends State<StudentItem> {
///声音是否开启
bool isSpeakerOpen = widget.user.speekerStatus == 1;
return ClipRRect(
borderRadius: BorderRadius.circular(10),
child: Container(
@@ -51,15 +55,18 @@ class _StudentItemState extends State<StudentItem> {
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<StudentItem> {
),
),
),
///右上角选中
if (widget.user.userId != vm.activeSId)
Positioned(
right: 5,
@@ -99,6 +108,17 @@ class _StudentItemState extends State<StudentItem> {
),
),
),
///举手
if (widget.user.handup == 1)
Positioned(
bottom: 40,
child: HandRaiseButton(
onTap: () {
vm.clearHandUp(widget.user.userId);
},
),
),
],
),
),
@@ -106,6 +126,7 @@ class _StudentItemState extends State<StudentItem> {
ColoredBox(
color: Color(0xFF232426),
child: Row(
spacing: 1,
children: [
_actionItem(
icon: isCameraOpen ? RemixIcons.video_on_fill : RemixIcons.video_off_fill,

View File

@@ -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<QiuTokenDto> getQiuTokenApi(String fileKey) async {
var response = await Request().get("/files/get_qiniu_upload_token", {
"file_key": fileKey,
});
return QiuTokenDto.fromJson(response);
}
///获取APP最新版本
Future<VersionDto> getAppVersionApi() async {
var response = await Request().get("/get_latest_version");
return VersionDto.fromJson(response);
}

View File

@@ -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<List<RoomInfoDto>> getRoomListApi() async {
Future<List<RoomListItemDto >> getRoomListApi() async {
var res = await Request().get('/study_room/get_study_room_list');
return List<RoomInfoDto>.from(res.map((x) => RoomInfoDto.fromJson(x)));
return List<RoomListItemDto >.from(res.map((x) => RoomListItemDto .fromJson(x)));
}
///获取自习室的websocket令牌

View File

@@ -0,0 +1,24 @@
class QiuTokenDto {
String? uploadUrl;
String? upToken;
String? fileKey;
String? domain;
QiuTokenDto({this.uploadUrl, this.upToken, this.fileKey, this.domain});
Map<String, dynamic> toJson() {
final map = <String, dynamic>{};
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"] ?? "";
}
}

View File

@@ -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<String> updateContent;
DateTime createdAt;
String lowVersion;
int id;
String downloadSize;
int platform;
factory VersionDto.fromJson(Map<dynamic, dynamic> json) => VersionDto(
latestVersion: json["latest_version"],
updatedAt: DateTime.parse(json["updated_at"]),
downloadUrl: json["download_url"],
updateContent: List<String>.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<dynamic, dynamic> toJson() => {
"latest_version": latestVersion,
"updated_at": updatedAt.toIso8601String(),
"download_url": downloadUrl,
"update_content": List<dynamic>.from(updateContent.map((x) => x)),
"created_at": createdAt.toIso8601String(),
"low_version": lowVersion,
"id": id,
"download_size": downloadSize,
"platform": platform,
};
}

View File

@@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<dynamic, dynamic> 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<dynamic, dynamic> 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<String, dynamic> 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"] ?? "",
);
}
}

View File

@@ -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<dynamic, dynamic> 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<dynamic, dynamic> 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,
};
}

View File

@@ -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<String, dynamic> toJson() {
final map = <String, dynamic>{};
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<String, dynamic> 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"] ?? "",
);
}
}

View File

@@ -10,7 +10,7 @@ class RoomUserDto {
/// 1是学生2是老师
final int userType;
final List<String> filesList;
List<String> filesList;
final String dataType;
int handup;
int online; //0离线1在线

View File

@@ -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"),

View File

@@ -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");
}

6
lib/utils/common.dart Normal file
View File

@@ -0,0 +1,6 @@
import 'dart:io';
///判断是否是安卓
bool isAndroid(){
return Platform.isAndroid;
}

View File

@@ -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();

View File

@@ -0,0 +1,81 @@
//下载文件
import 'dart:io';
import 'package:path_provider/path_provider.dart';
class LocalDownload {
static Future<String> 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<int> chunkList = [];
if (response.statusCode == 200) {
response.listen(
(List<int> 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<String> getFilePath({
required url,
required path,
}) async {
//获取本地文件路径
String filePath = await getLocalFilePath(url, path);
File file = File(filePath);
if (file.existsSync()) {
return file.path;
} else {
return '';
}
}
}

View File

@@ -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<QiuTokenDto> _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<String?> 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;
}
}
}

View File

@@ -0,0 +1,26 @@
import 'package:flutter/material.dart';
import 'action_sheet_ui.dart';
import 'type.dart';
void showActionSheet(
BuildContext context, {
required List<ActionSheetItem> 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,
);
},
);
}

View File

@@ -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<ActionSheetItem> 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("取消"),
),
),
],
),
),
],
),
);
}
}

View File

@@ -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,
});
}

View File

@@ -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,

View File

@@ -40,6 +40,9 @@ class FilePreviewer extends StatelessWidget {
child = InteractiveViewer(
child: CachedNetworkImage(
imageUrl: url,
placeholder: (_, __) => const Center(
child: CircularProgressIndicator(),
),
),
);
} else if (_isPdf(suffix)) {

View File

@@ -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();
}
}

View File

@@ -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<String> files = const [],
ValueChanged<List<String>>? 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<String> files;
final bool isUpload;
final ValueChanged<List<String>>? onConfirm;
const FileDrawer({super.key, this.isUpload = true});
const FileDrawer({
super.key,
this.name,
this.isUpload = true,
this.files = const [],
this.onConfirm,
});
@override
State<FileDrawer> createState() => _FileDrawerState();
}
class _FileDrawerState extends State<FileDrawer> {
///文件列表
List<String> _fileList = [];
@override
void initState() {
super.initState();
_fileList = List<String>.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<File> 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<String>().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<String>().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<String> uploadedPaths = (await Future.wait(
uploadTasks,
)).whereType<String>().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<FileDrawer> {
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<FileDrawer> {
);
}
}
///数据类
class UploadFileItem {
String url;
String name;
bool loading;
UploadFileItem({required this.url, this.loading = false, required this.name});
}

View File

@@ -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,
),
),
);
}
}

View File

@@ -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)),
);
}
}

View File

@@ -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<String, dynamic> 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<String> v1Parts = version1.split('.');
List<String> 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;
}

View File

@@ -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<String> updateNotice;
final String uploadUrl; //下载地址
const AppUpdateUi({
super.key,
required this.version,
required this.updateNotice,
required this.uploadUrl,
});
@override
State<AppUpdateUi> createState() => _UpdateUiState();
}
class _UpdateUiState extends State<AppUpdateUi> {
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,
),
),
],
),
),
),
],
),
),
),
);
}
}

View File

@@ -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"

View File

@@ -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: